Skip to content

Commit d5df127

Browse files
authored
chore: add ability to update library specific version files in release-init (googleapis#14350)
This PR builds on top of googleapis#14349 and adds the ability to update the client library version in client library version files using the `librarian release init` command such as - [gapic_version.py](https://github.com/googleapis/google-cloud-python/blob/main/packages/google-cloud-language/google/cloud/language_v1/gapic_version.py) - [snippet_metadata*.json](https://github.com/googleapis/google-cloud-python/blob/main/packages/google-cloud-language/samples/generated_samples/snippet_metadata_google.cloud.language.v2.json) Towards googleapis/librarian#886
1 parent a72bed4 commit d5df127

File tree

2 files changed

+179
-3
lines changed

2 files changed

+179
-3
lines changed

.generator/cli.py

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ def _read_json_file(path: str) -> Dict:
9191
return json.load(f)
9292

9393

94+
def _write_json_file(path: str, updated_content: Dict):
95+
"""Helper function that writes a json file with the given dictionary.
96+
97+
Args:
98+
path(str): The file path to write.
99+
updated_content(Dict): The dictionary to write.
100+
"""
101+
102+
with open(path, "w") as f:
103+
json.dump(updated_content, f, indent=2)
104+
f.write("\n")
105+
106+
94107
def handle_configure():
95108
# TODO(https://github.com/googleapis/librarian/issues/466): Implement configure command and update docstring.
96109
logger.info("'configure' command executed.")
@@ -472,7 +485,9 @@ def _get_libraries_to_prepare_for_release(library_entries: Dict) -> List[dict]:
472485
]
473486

474487

475-
def _update_global_changelog(changelog_src: str, changelog_dest: str, all_libraries: List[dict]):
488+
def _update_global_changelog(
489+
changelog_src: str, changelog_dest: str, all_libraries: List[dict]
490+
):
476491
"""Updates the versions of libraries in the main CHANGELOG.md.
477492
478493
Args:
@@ -498,6 +513,71 @@ def replace_version_in_changelog(content):
498513
_write_text_file(changelog_dest, updated_content)
499514

500515

516+
def _process_version_file(content, version, version_path) -> str:
517+
"""This function searches for a version string in the
518+
given content, replaces the version and returns the content.
519+
520+
Args:
521+
content(str): The contents where the version string should be replaced.
522+
version(str): The new version of the library.
523+
version_path(str): The relative path to the version file
524+
525+
Raises: ValueError if the version string could not be found in the given content
526+
527+
Returns: A string with the modified content.
528+
"""
529+
pattern = r"(__version__\s*=\s*[\"'])([^\"']+)([\"'].*)"
530+
replacement_string = f"\\g<1>{version}\\g<3>"
531+
new_content, num_replacements = re.subn(pattern, replacement_string, content)
532+
if num_replacements == 0:
533+
raise ValueError(
534+
f"Could not find version string in {version_path}. File was not modified."
535+
)
536+
return new_content
537+
538+
539+
def _update_version_for_library(
540+
repo: str, output: str, path_to_library: str, version: str
541+
):
542+
"""Updates the version string in `**/gapic_version.py` and `samples/**/snippet_metadata.json`
543+
for a given library.
544+
545+
Args:
546+
repo(str): This directory will contain all directories that make up a
547+
library, the .librarian folder, and any global file declared in
548+
the config.yaml.
549+
output(str): Path to the directory in the container where modified
550+
code should be placed.
551+
path_to_library(str): Relative path to the library to update
552+
version(str): The new version of the library
553+
554+
Raises: `ValueError` if a version string could not be located in `**/gapic_version.py`
555+
within the given library.
556+
"""
557+
558+
# Find and update gapic_version.py files
559+
gapic_version_files = Path(f"{repo}/{path_to_library}").rglob("**/gapic_version.py")
560+
for version_file in gapic_version_files:
561+
updated_content = _process_version_file(
562+
_read_text_file(version_file), version, version_file
563+
)
564+
output_path = f"{output}/{version_file.relative_to(repo)}"
565+
_write_text_file(output_path, updated_content)
566+
567+
# Find and update snippet_metadata.json files
568+
snippet_metadata_files = Path(f"{repo}/{path_to_library}").rglob(
569+
"samples/**/*.json"
570+
)
571+
for metadata_file in snippet_metadata_files:
572+
output_path = f"{output}/{metadata_file.relative_to(repo)}"
573+
os.makedirs(Path(output_path).parent, exist_ok=True)
574+
shutil.copy(metadata_file, output_path)
575+
576+
metadata_contents = _read_json_file(metadata_file)
577+
metadata_contents["clientLibrary"]["version"] = version
578+
_write_json_file(output_path, metadata_contents)
579+
580+
501581
def handle_release_init(
502582
librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR
503583
):
@@ -536,7 +616,11 @@ def handle_release_init(
536616
# Prepare the release for each library by updating the
537617
# library specific version files and library specific changelog.
538618
for library_release_data in libraries_to_prep_for_release:
539-
# TODO(https://github.com/googleapis/google-cloud-python/pull/14350):
619+
version = library_release_data["version"]
620+
package_name = library_release_data["id"]
621+
path_to_library = f"packages/{package_name}"
622+
_update_version_for_library(repo, output, path_to_library, version)
623+
540624
# Update library specific version files.
541625
# TODO(https://github.com/googleapis/google-cloud-python/pull/14353):
542626
# Conditionally update the library specific CHANGELOG if there is a change.

.generator/test_cli.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@
1515
import json
1616
import logging
1717
import os
18+
import pathlib
1819
import subprocess
1920
import unittest.mock
2021
from unittest.mock import MagicMock, mock_open
2122

2223
import pytest
23-
2424
from cli import (
2525
GENERATE_REQUEST_FILE,
2626
BUILD_REQUEST_FILE,
@@ -34,12 +34,15 @@
3434
_get_library_id,
3535
_get_libraries_to_prepare_for_release,
3636
_locate_and_extract_artifact,
37+
_process_version_file,
3738
_read_json_file,
3839
_read_text_file,
3940
_run_individual_session,
4041
_run_nox_sessions,
4142
_run_post_processor,
4243
_update_global_changelog,
44+
_update_version_for_library,
45+
_write_json_file,
4346
_write_text_file,
4447
handle_build,
4548
handle_configure,
@@ -514,6 +517,7 @@ def test_handle_release_init_success(mocker, mock_release_init_request_file):
514517
Simply tests that `handle_release_init` runs without errors.
515518
"""
516519
mocker.patch("cli._update_global_changelog", return_value=None)
520+
mocker.patch("cli._update_version_for_library", return_value=None)
517521
handle_release_init()
518522

519523

@@ -554,6 +558,31 @@ def test_write_text_file():
554558
handle.write.assert_called_once_with("modified content")
555559

556560

561+
def test_write_json_file():
562+
"""Tests writing a json file.
563+
See https://docs.python.org/3/library/unittest.mock.html#mock-open
564+
"""
565+
m = mock_open()
566+
567+
expected_dict = {"name": "call me json"}
568+
569+
with unittest.mock.patch("cli.open", m):
570+
_write_json_file("fake_path.json", expected_dict)
571+
572+
handle = m()
573+
# Get all the arguments passed to the mock's write method
574+
# and join them into a single string.
575+
written_content = "".join(
576+
[call.args[0] for call in handle.write.call_args_list]
577+
)
578+
579+
# Create the expected output string with the correct formatting.
580+
expected_output = json.dumps(expected_dict, indent=2) + "\n"
581+
582+
# Assert that the content written to the mock file matches the expected output.
583+
assert written_content == expected_output
584+
585+
557586
def test_update_global_changelog(mocker, mock_release_init_request_file):
558587
"""Tests that the global changelog is updated
559588
with the new version for a given library.
@@ -571,3 +600,66 @@ def test_update_global_changelog(mocker, mock_release_init_request_file):
571600

572601
handle = m()
573602
handle.write.assert_called_once_with("[google-cloud-language==1.2.3]")
603+
604+
605+
def test_update_version_for_library_success(mocker):
606+
m = mock_open()
607+
608+
mock_rglob = mocker.patch(
609+
"pathlib.Path.rglob", return_value=[pathlib.Path("repo/gapic_version.py")]
610+
)
611+
mock_shutil_copy = mocker.patch("shutil.copy")
612+
mock_content = '__version__ = "1.2.2"'
613+
mock_json_metadata = {"clientLibrary": {"version": "0.1.0"}}
614+
615+
with unittest.mock.patch("cli.open", m):
616+
mocker.patch("cli._read_text_file", return_value=mock_content)
617+
mocker.patch("cli._read_json_file", return_value=mock_json_metadata)
618+
_update_version_for_library(
619+
"repo", "output", "packages/google-cloud-language", "1.2.3"
620+
)
621+
622+
handle = m()
623+
assert handle.write.call_args_list[0].args[0] == '__version__ = "1.2.3"'
624+
625+
# Get all the arguments passed to the mock's write method
626+
# and join them into a single string.
627+
written_content = "".join(
628+
[call.args[0] for call in handle.write.call_args_list[1:]]
629+
)
630+
# Create the expected output string with the correct formatting.
631+
assert (
632+
written_content
633+
== '{\n "clientLibrary": {\n "version": "1.2.3"\n }\n}\n'
634+
)
635+
636+
637+
def test_update_version_for_library_failure(mocker):
638+
"""Tests that value error is raised if the version string cannot be found"""
639+
m = mock_open()
640+
641+
mock_rglob = mocker.patch(
642+
"pathlib.Path.rglob", return_value=[pathlib.Path("repo/gapic_version.py")]
643+
)
644+
mock_content = "not found"
645+
with pytest.raises(ValueError):
646+
with unittest.mock.patch("cli.open", m):
647+
mocker.patch("cli._read_text_file", return_value=mock_content)
648+
_update_version_for_library(
649+
"repo", "output", "packages/google-cloud-language", "1.2.3"
650+
)
651+
652+
653+
def test_process_version_file_success():
654+
version_file_contents = '__version__ = "1.2.2"'
655+
new_version = "1.2.3"
656+
modified_content = _process_version_file(
657+
version_file_contents, new_version, "file.txt"
658+
)
659+
assert modified_content == f'__version__ = "{new_version}"'
660+
661+
662+
def test_process_version_file_failure():
663+
"""Tests that value error is raised if the version string cannot be found"""
664+
with pytest.raises(ValueError):
665+
_process_version_file("", "", "")

0 commit comments

Comments
 (0)