From b5dace71b6bde367c63a1de62da0a576faede383 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Sat, 30 Aug 2025 00:11:09 +0000 Subject: [PATCH 01/20] chore: add support for librarian release init --- .generator/cli.py | 282 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 265 insertions(+), 17 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index 4b7892456237..ee43fa0dd09d 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -14,6 +14,7 @@ import argparse import glob +import itertools import json import logging import os @@ -21,9 +22,12 @@ import shutil import subprocess import sys +from datetime import datetime from pathlib import Path from typing import Dict, List +import yaml + try: import synthtool from synthtool.languages import python_mono_repo @@ -36,13 +40,16 @@ logger = logging.getLogger() -LIBRARIAN_DIR = "librarian" +BUILD_REQUEST_FILE = "build-request.json" GENERATE_REQUEST_FILE = "generate-request.json" +RELEASE_INIT_REQUEST_FILE = "release-init-request.json" +STATE_YAML_FILE = "state.yaml" + INPUT_DIR = "input" -BUILD_REQUEST_FILE = "build-request.json" -SOURCE_DIR = "source" +LIBRARIAN_DIR = "librarian" OUTPUT_DIR = "output" REPO_DIR = "repo" +SOURCE_DIR = "source" def _read_json_file(path: str) -> Dict: @@ -433,23 +440,256 @@ def handle_build(librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR): logger.info("'build' command executed.") -if __name__ == "__main__": # pragma: NO COVER +def _read_and_process_file(input_path: str, output_path: str, process_func) -> None: + """Helper function to read, process, and write a file. + + Args: + input_path (str): The path to the file to read. + output_path (str): The path to the file to write. + process_func (callable): A function that takes the file content as a string + and returns the modified string. + """ + os.makedirs(Path(output_path).parent, exist_ok=True) + shutil.copy(input_path, output_path) + + with open(output_path, "r", encoding="utf-8") as f: + content = f.read() + + updated_content = process_func(content) + + with open(output_path, "w", encoding="utf-8") as f: + f.write(updated_content) + + +def _get_libraries_to_prepare_for_release(library_entries: Dict) -> List[dict]: + """Note the request data may have multiple libraries in it. Locate all + libraries which should be prepared for release. This can be done by + checking whether the `release_triggered` field is `true`. + + Args: + library_entries(Dict): Dictionary containing all of the libraries + present in the repository. + + Returns: + List[dict]: List of all libraries which should be prepared for release, + along with the corresponding information for the release. + """ + return [ + library + for library in library_entries["libraries"] + if library.get("release_triggered") + ] + + +def handle_release_init( + librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR +): + """The main coordinator for the release process. + + This function prepares for the release of client libraries by reading a + `librarian/release-init-request.json` file. The primary responsibility is + to update all required files with the new version and commit information + for libraries that have the `release_triggered` field set to `true`. + + See https://github.com/googleapis/librarian/blob/main/doc/container-contract.md#generate-container-command + + Args: + librarian(str): Path to the directory in the container which contains + the `release-init-request.json` file + repo(str): This directory will contain all directories that make up a + library, the .librarian folder, and any global file declared in + the config.yaml. + output(str): Path to the directory in the container where modified + code should be placed. + """ + + try: + # Read a release-init-request.json file + request_data = _read_json_file(f"{librarian}/{RELEASE_INIT_REQUEST_FILE}") + libraries_to_prep_for_release = _get_libraries_to_prepare_for_release( + request_data + ) + + # Update the main changelog file + source_path = f"{repo}/CHANGELOG.md" + output_path = f"{output}/CHANGELOG.md" + _update_global_changelog( + source_path, output_path, libraries_to_prep_for_release + ) + + # Process each library to be released + for library_release_data in libraries_to_prep_for_release: + version = library_release_data["version"] + library_changes = library_release_data["changes"] + package_name = library_release_data["id"] + path_to_library = f"packages/{package_name}" + + # Get previous version from state.yaml + previous_version = _get_previous_version(repo, package_name) + + _update_version_for_library(repo, output, path_to_library, version) + if previous_version != version: + _update_changelog_for_library( + repo, + output, + library_changes, + version, + previous_version, + package_name, + ) + + except Exception as e: + raise ValueError(f"Release init failed: {e}") from e + + +def _get_previous_version(repo: str, package_name: str) -> str: + """Gets the previous version of the library from state.yaml.""" + state_yaml_path = f"{repo}/.librarian/{STATE_YAML_FILE}" + if not os.path.exists(state_yaml_path): + raise FileNotFoundError(f"State file not found at {state_yaml_path}") + + with open(state_yaml_path, "r") as state_yaml_file: + state_yaml = yaml.safe_load(state_yaml_file) + for library in state_yaml.get("libraries", []): + if library.get("id") == package_name: + return library.get("version") + + raise ValueError( + f"Could not determine previous version for {package_name} from state.yaml" + ) + + +def _update_global_changelog(source_path: str, output_path: str, libraries: List[dict]): + """Updates the main CHANGELOG.md with new versions.""" + + def process_content(content): + new_content = content + for lib in libraries: + package_name = lib["id"] + version = lib["version"] + pattern = re.compile(f"(\\[{re.escape(package_name)})(==)([\\d\\.]+)(\\])") + replacement = f"\\g<1>=={version}\\g<4>" + new_content = pattern.sub(replacement, new_content) + return new_content + + _read_and_process_file(source_path, output_path, process_content) + + +def _update_changelog_for_library( + repo: str, + output: str, + library_changes: List[Dict], + version: str, + previous_version: str, + package_name: str, +): + """Prepends a new release entry with multiple, grouped changes to a changelog.""" + + def process_content(content): + repo_url = "https://github.com/googleapis/google-cloud-python" + current_date = datetime.now().strftime("%Y-%m-%d") + + # Create the main version header + version_header = ( + f"## [{version}]({repo_url}/compare/{package_name}-v{previous_version}" + f"...{package_name}-v{version}) ({current_date})" + ) + entry_parts = [version_header] + + # Group changes by type (e.g., feat, fix, docs) + library_changes.sort(key=lambda x: x["type"]) + grouped_changes = itertools.groupby(library_changes, key=lambda x: x["type"]) + + for change_type, changes in grouped_changes: + # We only care about feat, fix, docs + if change_type.replace("!", "") in ["feat", "fix", "docs"]: + entry_parts.append( + f"\n\n### {change_type.capitalize().replace('!', '')}\n" + ) + for change in changes: + commit_link = f"([{change['source_commit_hash']}]({repo_url}/commit/{change['source_commit_hash']}))" + entry_parts.append(f"* {change['subject']} {commit_link}") + + new_entry_text = "\n".join(entry_parts) + anchor_pattern = re.compile( + r"(\[1\]: https://pypi\.org/project/google-cloud-language/#history)", + re.MULTILINE, + ) + replacement_text = f"\\g<1>\n\n{new_entry_text}" + updated_content, num_subs = anchor_pattern.subn( + replacement_text, content, count=1 + ) + + if num_subs == 0: + raise ValueError("Changelog anchor '[1]: ...#history' not found.") + + return updated_content + + source_path = f"{repo}/packages/{package_name}/CHANGELOG.md" + output_path = f"{output}/packages/{package_name}/CHANGELOG.md" + _read_and_process_file(source_path, output_path, process_content) + + +def _update_version_for_library( + repo: str, output: str, path_to_library: str, version: str +): + """Updates the version string in various files for a library.""" + + # Find and update gapic_version.py files + gapic_version_files = Path(f"{repo}/{path_to_library}").rglob("**/gapic_version.py") + for version_file in gapic_version_files: + + def process_version_file(content): + pattern = r"(__version__\s*=\s*[\"'])([^\"']+)([\"'].*)" + replacement_string = f"\\g<1>{version}\\g<3>" + new_content, num_replacements = re.subn( + pattern, replacement_string, content + ) + if num_replacements == 0: + raise Exception( + f"Could not find version string in {version_file}. File was not modified." + ) + return new_content + + output_path = f"{output}/{version_file.relative_to(repo)}" + _read_and_process_file(str(version_file), output_path, process_version_file) + + # Find and update snippet_metadata.json files + snippet_metadata_files = Path(f"{repo}/{path_to_library}").rglob( + "samples/**/*.json" + ) + for metadata_file in snippet_metadata_files: + output_path = f"{output}/{metadata_file.relative_to(repo)}" + os.makedirs(Path(output_path).parent, exist_ok=True) + shutil.copy(metadata_file, output_path) + + metadata_contents = _read_json_file(metadata_file) + metadata_contents["clientLibrary"]["version"] = version + + with open(output_path, "w") as f: + json.dump(metadata_contents, f, indent=2) + f.write("\n") + + +def main(): parser = argparse.ArgumentParser(description="A simple CLI tool.") subparsers = parser.add_subparsers( dest="command", required=True, help="Available commands" ) - # Define commands + # Define commands and their corresponding handler functions handler_map = { "configure": handle_configure, "generate": handle_generate, "build": handle_build, + "release-init": handle_release_init, } for command_name, help_text in [ ("configure", "Onboard a new library or an api path to Librarian workflow."), ("generate", "generate a python client for an API."), ("build", "Run unit tests via nox for the generated library."), + ("release-init", "Prepare to release a specific library"), ]: parser_cmd = subparsers.add_parser(command_name, help=help_text) parser_cmd.set_defaults(func=handler_map[command_name]) @@ -483,21 +723,29 @@ def handle_build(librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR): help="Path to the directory in the container which contains google-cloud-python repository", default=SOURCE_DIR, ) + if len(sys.argv) == 1: parser.print_help(sys.stderr) sys.exit(1) args = parser.parse_args() - # Pass specific arguments to the handler functions for generate/build - if args.command == "generate": - args.func( - librarian=args.librarian, - source=args.source, - output=args.output, - input=args.input, - ) - elif args.command == "build": - args.func(librarian=args.librarian, repo=args.repo) - else: - args.func() + # Pass arguments to the selected handler function using a dynamic approach + handler_args = { + k: v for k, v in vars(args).items() if k != "command" and k != "func" + } + # Filter out arguments not expected by the function + import inspect + + handler_func = args.func + arg_spec = inspect.getfullargspec(handler_func) + + valid_handler_args = { + k: v for k, v in handler_args.items() if k in arg_spec.args or arg_spec.varkw + } + + handler_func(**valid_handler_args) + + +if __name__ == "__main__": # pragma: NO COVER + main() From 4dabaf62ddec2fd39b26e4e5b17e90a5e88e6229 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 10:32:37 +0000 Subject: [PATCH 02/20] split code changes --- .generator/cli.py | 199 ++++++---------------------------------------- 1 file changed, 24 insertions(+), 175 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index ee43fa0dd09d..7d0b235e3ace 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -14,7 +14,6 @@ import argparse import glob -import itertools import json import logging import os @@ -22,12 +21,9 @@ import shutil import subprocess import sys -from datetime import datetime from pathlib import Path from typing import Dict, List -import yaml - try: import synthtool from synthtool.languages import python_mono_repo @@ -43,7 +39,6 @@ BUILD_REQUEST_FILE = "build-request.json" GENERATE_REQUEST_FILE = "generate-request.json" RELEASE_INIT_REQUEST_FILE = "release-init-request.json" -STATE_YAML_FILE = "state.yaml" INPUT_DIR = "input" LIBRARIAN_DIR = "librarian" @@ -378,7 +373,6 @@ def handle_generate( except Exception as e: raise ValueError("Generation failed.") from e - # TODO(https://github.com/googleapis/librarian/issues/448): Implement generate command and update docstring. logger.info("'generate' command executed.") @@ -510,166 +504,22 @@ def handle_release_init( request_data ) - # Update the main changelog file - source_path = f"{repo}/CHANGELOG.md" - output_path = f"{output}/CHANGELOG.md" - _update_global_changelog( - source_path, output_path, libraries_to_prep_for_release - ) + # TODO(https://github.com/googleapis/google-cloud-python/pull/14350): + # Update library global changelog file. - # Process each library to be released + # Prepare the release for each library by updating the + # library specific version files and library specific changelog. for library_release_data in libraries_to_prep_for_release: - version = library_release_data["version"] - library_changes = library_release_data["changes"] - package_name = library_release_data["id"] - path_to_library = f"packages/{package_name}" - - # Get previous version from state.yaml - previous_version = _get_previous_version(repo, package_name) - - _update_version_for_library(repo, output, path_to_library, version) - if previous_version != version: - _update_changelog_for_library( - repo, - output, - library_changes, - version, - previous_version, - package_name, - ) + # TODO(https://github.com/googleapis/google-cloud-python/pull/14351): + # Update library specific version files. + # TODO(https://github.com/googleapis/google-cloud-python/pull/14352): + # Conditionally update the library specific CHANGELOG if there is a change. + pass except Exception as e: raise ValueError(f"Release init failed: {e}") from e - -def _get_previous_version(repo: str, package_name: str) -> str: - """Gets the previous version of the library from state.yaml.""" - state_yaml_path = f"{repo}/.librarian/{STATE_YAML_FILE}" - if not os.path.exists(state_yaml_path): - raise FileNotFoundError(f"State file not found at {state_yaml_path}") - - with open(state_yaml_path, "r") as state_yaml_file: - state_yaml = yaml.safe_load(state_yaml_file) - for library in state_yaml.get("libraries", []): - if library.get("id") == package_name: - return library.get("version") - - raise ValueError( - f"Could not determine previous version for {package_name} from state.yaml" - ) - - -def _update_global_changelog(source_path: str, output_path: str, libraries: List[dict]): - """Updates the main CHANGELOG.md with new versions.""" - - def process_content(content): - new_content = content - for lib in libraries: - package_name = lib["id"] - version = lib["version"] - pattern = re.compile(f"(\\[{re.escape(package_name)})(==)([\\d\\.]+)(\\])") - replacement = f"\\g<1>=={version}\\g<4>" - new_content = pattern.sub(replacement, new_content) - return new_content - - _read_and_process_file(source_path, output_path, process_content) - - -def _update_changelog_for_library( - repo: str, - output: str, - library_changes: List[Dict], - version: str, - previous_version: str, - package_name: str, -): - """Prepends a new release entry with multiple, grouped changes to a changelog.""" - - def process_content(content): - repo_url = "https://github.com/googleapis/google-cloud-python" - current_date = datetime.now().strftime("%Y-%m-%d") - - # Create the main version header - version_header = ( - f"## [{version}]({repo_url}/compare/{package_name}-v{previous_version}" - f"...{package_name}-v{version}) ({current_date})" - ) - entry_parts = [version_header] - - # Group changes by type (e.g., feat, fix, docs) - library_changes.sort(key=lambda x: x["type"]) - grouped_changes = itertools.groupby(library_changes, key=lambda x: x["type"]) - - for change_type, changes in grouped_changes: - # We only care about feat, fix, docs - if change_type.replace("!", "") in ["feat", "fix", "docs"]: - entry_parts.append( - f"\n\n### {change_type.capitalize().replace('!', '')}\n" - ) - for change in changes: - commit_link = f"([{change['source_commit_hash']}]({repo_url}/commit/{change['source_commit_hash']}))" - entry_parts.append(f"* {change['subject']} {commit_link}") - - new_entry_text = "\n".join(entry_parts) - anchor_pattern = re.compile( - r"(\[1\]: https://pypi\.org/project/google-cloud-language/#history)", - re.MULTILINE, - ) - replacement_text = f"\\g<1>\n\n{new_entry_text}" - updated_content, num_subs = anchor_pattern.subn( - replacement_text, content, count=1 - ) - - if num_subs == 0: - raise ValueError("Changelog anchor '[1]: ...#history' not found.") - - return updated_content - - source_path = f"{repo}/packages/{package_name}/CHANGELOG.md" - output_path = f"{output}/packages/{package_name}/CHANGELOG.md" - _read_and_process_file(source_path, output_path, process_content) - - -def _update_version_for_library( - repo: str, output: str, path_to_library: str, version: str -): - """Updates the version string in various files for a library.""" - - # Find and update gapic_version.py files - gapic_version_files = Path(f"{repo}/{path_to_library}").rglob("**/gapic_version.py") - for version_file in gapic_version_files: - - def process_version_file(content): - pattern = r"(__version__\s*=\s*[\"'])([^\"']+)([\"'].*)" - replacement_string = f"\\g<1>{version}\\g<3>" - new_content, num_replacements = re.subn( - pattern, replacement_string, content - ) - if num_replacements == 0: - raise Exception( - f"Could not find version string in {version_file}. File was not modified." - ) - return new_content - - output_path = f"{output}/{version_file.relative_to(repo)}" - _read_and_process_file(str(version_file), output_path, process_version_file) - - # Find and update snippet_metadata.json files - snippet_metadata_files = Path(f"{repo}/{path_to_library}").rglob( - "samples/**/*.json" - ) - for metadata_file in snippet_metadata_files: - output_path = f"{output}/{metadata_file.relative_to(repo)}" - os.makedirs(Path(output_path).parent, exist_ok=True) - shutil.copy(metadata_file, output_path) - - metadata_contents = _read_json_file(metadata_file) - metadata_contents["clientLibrary"]["version"] = version - - with open(output_path, "w") as f: - json.dump(metadata_contents, f, indent=2) - f.write("\n") - + logger.info("'release-init' command executed.") def main(): parser = argparse.ArgumentParser(description="A simple CLI tool.") @@ -730,21 +580,20 @@ def main(): args = parser.parse_args() - # Pass arguments to the selected handler function using a dynamic approach - handler_args = { - k: v for k, v in vars(args).items() if k != "command" and k != "func" - } - # Filter out arguments not expected by the function - import inspect - - handler_func = args.func - arg_spec = inspect.getfullargspec(handler_func) - - valid_handler_args = { - k: v for k, v in handler_args.items() if k in arg_spec.args or arg_spec.varkw - } - - handler_func(**valid_handler_args) + # Pass specific arguments to the handler functions for generate/build + if args.command == "generate": + args.func( + librarian=args.librarian, + source=args.source, + output=args.output, + input=args.input, + ) + elif args.command == "build": + args.func(librarian=args.librarian, repo=args.repo) + elif args.command == "release-init": + args.func(librarian=args.librarian, repo=args.repo, output=OUTPUT_DIR) + else: + args.func() if __name__ == "__main__": # pragma: NO COVER From 26348a9a42a465229b1487f301db0d8e9d8476cb Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 10:34:51 +0000 Subject: [PATCH 03/20] remove unused code --- .generator/cli.py | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index 7d0b235e3ace..bcf42d7a48cf 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -434,27 +434,6 @@ def handle_build(librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR): logger.info("'build' command executed.") -def _read_and_process_file(input_path: str, output_path: str, process_func) -> None: - """Helper function to read, process, and write a file. - - Args: - input_path (str): The path to the file to read. - output_path (str): The path to the file to write. - process_func (callable): A function that takes the file content as a string - and returns the modified string. - """ - os.makedirs(Path(output_path).parent, exist_ok=True) - shutil.copy(input_path, output_path) - - with open(output_path, "r", encoding="utf-8") as f: - content = f.read() - - updated_content = process_func(content) - - with open(output_path, "w", encoding="utf-8") as f: - f.write(updated_content) - - def _get_libraries_to_prepare_for_release(library_entries: Dict) -> List[dict]: """Note the request data may have multiple libraries in it. Locate all libraries which should be prepared for release. This can be done by From 1c0ad8ac5637ebb158634552d66af787b770eee1 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 10:37:25 +0000 Subject: [PATCH 04/20] update comment --- .generator/cli.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index bcf42d7a48cf..c7499a4513d2 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -435,13 +435,12 @@ def handle_build(librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR): def _get_libraries_to_prepare_for_release(library_entries: Dict) -> List[dict]: - """Note the request data may have multiple libraries in it. Locate all - libraries which should be prepared for release. This can be done by - checking whether the `release_triggered` field is `true`. + """Get libraries which should be prepared for release. Only libraries + which have the `release_triggered` field set to `true` will be returned. Args: - library_entries(Dict): Dictionary containing all of the libraries - present in the repository. + library_entries(Dict): Dictionary containing all of the libraries to + evaluate. Returns: List[dict]: List of all libraries which should be prepared for release, From c0402864d6bf6a44b28bb9dee757141f0324a2f3 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 10:38:13 +0000 Subject: [PATCH 05/20] update comment --- .generator/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.generator/cli.py b/.generator/cli.py index c7499a4513d2..29b0ce1cde0e 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -436,7 +436,7 @@ def handle_build(librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR): def _get_libraries_to_prepare_for_release(library_entries: Dict) -> List[dict]: """Get libraries which should be prepared for release. Only libraries - which have the `release_triggered` field set to `true` will be returned. + which have the `release_triggered` field set to `True` will be returned. Args: library_entries(Dict): Dictionary containing all of the libraries to From 52af9f567033a81434a83598c9585a59763ed6a4 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 10:49:31 +0000 Subject: [PATCH 06/20] wip --- .generator/cli.py | 1 + .generator/test_cli.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/.generator/cli.py b/.generator/cli.py index 29b0ce1cde0e..af94fd818d07 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -499,6 +499,7 @@ def handle_release_init( logger.info("'release-init' command executed.") + def main(): parser = argparse.ArgumentParser(description="A simple CLI tool.") subparsers = parser.add_subparsers( diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 74db90ee80e5..83fa00316b9f 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -23,6 +23,7 @@ from cli import ( GENERATE_REQUEST_FILE, BUILD_REQUEST_FILE, + RELEASE_INIT_REQUEST_FILE, LIBRARIAN_DIR, REPO_DIR, _build_bazel_target, @@ -30,6 +31,7 @@ _copy_files_needed_for_post_processing, _determine_bazel_rule, _get_library_id, + _get_libraries_to_prepare_for_release, _locate_and_extract_artifact, _read_json_file, _run_individual_session, @@ -92,6 +94,34 @@ def mock_generate_request_data_for_nox(): } +@pytest.fixture +def mock_release_init_request_file(tmp_path, monkeypatch): + """Creates the mock request file at the correct path inside a temp dir.""" + # Create the path as expected by the script: .librarian/release-request.json + request_path = f"{LIBRARIAN_DIR}/{RELEASE_INIT_REQUEST_FILE}" + request_dir = tmp_path / os.path.dirname(request_path) + request_dir.mkdir() + request_file = request_dir / os.path.basename(request_path) + + request_content = { + "libraries": [ + { + "id": "google-cloud-language", + "apis": [{"path": "google/cloud/language/v1"}], + "release_triggered": True, + "version": "1.2.3", + "changes": [], + }, + {}, + ] + } + request_file.write_text(json.dumps(request_content)) + + # Change the current working directory to the temp path for the test. + monkeypatch.chdir(tmp_path) + return request_file + + def test_get_library_id_success(): """Tests that _get_library_id returns the correct ID when present.""" request_data = {"id": "test-library", "name": "Test Library"} @@ -454,3 +484,9 @@ def test_clean_up_files_after_post_processing_success(mocker): mock_shutil_rmtree = mocker.patch("shutil.rmtree") mock_os_remove = mocker.patch("os.remove") _clean_up_files_after_post_processing("output", "library_id") + + +def test_get_libraries_to_prepare_for_release(mock_release_init_request_file): + request_data = _read_json_file(f"{LIBRARIAN_DIR}/{RELEASE_INIT_REQUEST_FILE}") + libraries_to_prep_for_release = _get_libraries_to_prepare_for_release(request_data) + assert "google-cloud-language" in libraries_to_prep_for_release[0]["id"] From e1d352b44c4ec73a2add2de8bd72ccda979bf623 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 11:04:22 +0000 Subject: [PATCH 07/20] add tests --- .generator/cli.py | 8 ++------ .generator/test_cli.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index af94fd818d07..acb520d43405 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -500,7 +500,7 @@ def handle_release_init( logger.info("'release-init' command executed.") -def main(): +if __name__ == "__main__": # pragma: NO COVER parser = argparse.ArgumentParser(description="A simple CLI tool.") subparsers = parser.add_subparsers( dest="command", required=True, help="Available commands" @@ -570,10 +570,6 @@ def main(): elif args.command == "build": args.func(librarian=args.librarian, repo=args.repo) elif args.command == "release-init": - args.func(librarian=args.librarian, repo=args.repo, output=OUTPUT_DIR) + args.func(librarian=args.librarian, repo=args.repo, output=args.output) else: args.func() - - -if __name__ == "__main__": # pragma: NO COVER - main() diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 83fa00316b9f..c2ee8f22f74b 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -40,6 +40,7 @@ handle_build, handle_configure, handle_generate, + handle_release_init, ) @@ -105,6 +106,13 @@ def mock_release_init_request_file(tmp_path, monkeypatch): request_content = { "libraries": [ + { + "id": "google-cloud-another-library", + "apis": [{"path": "google/cloud/another/library/v1"}], + "release_triggered": False, + "version": "1.2.3", + "changes": [], + }, { "id": "google-cloud-language", "apis": [{"path": "google/cloud/language/v1"}], @@ -112,7 +120,6 @@ def mock_release_init_request_file(tmp_path, monkeypatch): "version": "1.2.3", "changes": [], }, - {}, ] } request_file.write_text(json.dumps(request_content)) @@ -487,6 +494,27 @@ def test_clean_up_files_after_post_processing_success(mocker): def test_get_libraries_to_prepare_for_release(mock_release_init_request_file): + """ + Tests that only libraries with the `release_triggered` field set to `True` are + returned. + """ request_data = _read_json_file(f"{LIBRARIAN_DIR}/{RELEASE_INIT_REQUEST_FILE}") libraries_to_prep_for_release = _get_libraries_to_prepare_for_release(request_data) + assert len(libraries_to_prep_for_release) == 1 assert "google-cloud-language" in libraries_to_prep_for_release[0]["id"] + assert libraries_to_prep_for_release[0]["id"]["release_triggered"] + + +def test_handle_release_init_success(mock_release_init_request_file): + """ + Simply tests that `handle_release_init` runs without errors. + """ + handle_release_init() + + +def test_handle_release_init_fail(): + """ + Tests that handle_release_init fails to read `librarian/release-init-request.json`. + """ + with pytest.raises(ValueError): + handle_release_init() From bd770deab578f129d655c8ef54921e7416f12928 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 11:05:25 +0000 Subject: [PATCH 08/20] update comment --- .generator/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.generator/cli.py b/.generator/cli.py index acb520d43405..fa9c8ec62eed 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -456,7 +456,7 @@ def _get_libraries_to_prepare_for_release(library_entries: Dict) -> List[dict]: def handle_release_init( librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR ): - """The main coordinator for the release process. + """The main coordinator for the release preparation process. This function prepares for the release of client libraries by reading a `librarian/release-init-request.json` file. The primary responsibility is From 96934d9cb2e583f3aa37c448bfbd09ea19b5c1dc Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 11:05:49 +0000 Subject: [PATCH 09/20] update comment --- .generator/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.generator/cli.py b/.generator/cli.py index fa9c8ec62eed..108e96a04bab 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -461,7 +461,7 @@ def handle_release_init( This function prepares for the release of client libraries by reading a `librarian/release-init-request.json` file. The primary responsibility is to update all required files with the new version and commit information - for libraries that have the `release_triggered` field set to `true`. + for libraries that have the `release_triggered` field set to `True`. See https://github.com/googleapis/librarian/blob/main/doc/container-contract.md#generate-container-command From f6314b06bd09f64614c7bf940bc8746be8bdb895 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 11:06:02 +0000 Subject: [PATCH 10/20] update comment --- .generator/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.generator/cli.py b/.generator/cli.py index 108e96a04bab..8d7b305ea7e4 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -467,7 +467,7 @@ def handle_release_init( Args: librarian(str): Path to the directory in the container which contains - the `release-init-request.json` file + the `release-init-request.json` file. repo(str): This directory will contain all directories that make up a library, the .librarian folder, and any global file declared in the config.yaml. From 788a7aeae7951fc2a13330911c774e5d06a3362d Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 11:08:20 +0000 Subject: [PATCH 11/20] fix typo --- .generator/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.generator/test_cli.py b/.generator/test_cli.py index c2ee8f22f74b..cf43ccd0745c 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -502,7 +502,7 @@ def test_get_libraries_to_prepare_for_release(mock_release_init_request_file): libraries_to_prep_for_release = _get_libraries_to_prepare_for_release(request_data) assert len(libraries_to_prep_for_release) == 1 assert "google-cloud-language" in libraries_to_prep_for_release[0]["id"] - assert libraries_to_prep_for_release[0]["id"]["release_triggered"] + assert libraries_to_prep_for_release[0]["release_triggered"] def test_handle_release_init_success(mock_release_init_request_file): From 9240a7cdc7c4fc231f3e2c34259a3a19fe51c42b Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 11:11:23 +0000 Subject: [PATCH 12/20] update pull request nunmbers --- .generator/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index 8d7b305ea7e4..6b77a54c37b0 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -482,15 +482,15 @@ def handle_release_init( request_data ) - # TODO(https://github.com/googleapis/google-cloud-python/pull/14350): + # TODO(https://github.com/googleapis/google-cloud-python/pull/14349): # Update library global changelog file. # Prepare the release for each library by updating the # library specific version files and library specific changelog. for library_release_data in libraries_to_prep_for_release: - # TODO(https://github.com/googleapis/google-cloud-python/pull/14351): + # TODO(https://github.com/googleapis/google-cloud-python/pull/14350): # Update library specific version files. - # TODO(https://github.com/googleapis/google-cloud-python/pull/14352): + # TODO(https://github.com/googleapis/google-cloud-python/pull/14351): # Conditionally update the library specific CHANGELOG if there is a change. pass From 0691dacddcb423f7e2e2c759fcea3dada8d2c454 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 14:11:31 +0000 Subject: [PATCH 13/20] chore: add ability to update the global changelog via release-init command --- .generator/cli.py | 72 ++++++++++++++++++++++++++++++++++++++++-- .generator/test_cli.py | 57 +++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index 6b77a54c37b0..b68583fac563 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -47,6 +47,43 @@ SOURCE_DIR = "source" +def _read_text_file(path: str) -> str: + """Helper function that reads a text file path and returns the content. + + Args: + path(str): The file path to read. + + Returns: + str: The contents of the file. + + Raises: + FileNotFoundError: If the file is not found at the specified path. + IOError: If there is an issue reading the file. + """ + + with open(path, "r") as f: + return f.read() + + +def _write_text_file(path: str, updated_content: str) -> str: + """Helper function that reads a text file path and returns the content. + + Args: + path(str): The file path to read. + updated_content(str): The contents to write to the file. + + Returns: + None + + Raises: + FileNotFoundError: If the file is not found at the specified path. + IOError: If there is an issue writing the file. + """ + + with open(path, "w") as f: + f.write(updated_content) + + def _read_json_file(path: str) -> Dict: """Helper function that reads a json file path and returns the loaded json content. @@ -453,6 +490,34 @@ def _get_libraries_to_prepare_for_release(library_entries: Dict) -> List[dict]: ] +def _update_global_changelog(source_path: str, output_path: str, libraries: List[dict]): + """Updates the versions of libraries in the main CHANGELOG.md. + + Args: + source_path(str): Path to the changelog file to read. + output_path(str): Path to the changelog file to write. + libraries(Dict): Dictionary containing all of the library versions to + modify. + + Returns: None + """ + + def replace_version_in_changelog(content): + new_content = content + for individual_library in libraries: + package_name = individual_library["id"] + version = individual_library["version"] + # Find the entry for the given package in the format`==` + # Replace the `` part of the string. + pattern = re.compile(f"(\\[{re.escape(package_name)})(==)([\\d\\.]+)(\\])") + replacement = f"\\g<1>=={version}\\g<4>" + new_content = pattern.sub(replacement, new_content) + return new_content + + updated_content = replace_version_in_changelog(_read_text_file(source_path)) + _write_text_file(output_path, updated_content) + + def handle_release_init( librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR ): @@ -482,8 +547,11 @@ def handle_release_init( request_data ) - # TODO(https://github.com/googleapis/google-cloud-python/pull/14349): - # Update library global changelog file. + _update_global_changelog( + f"{repo}/CHANGELOG.md", + f"{output}/CHANGELOG.md", + libraries_to_prep_for_release, + ) # Prepare the release for each library by updating the # library specific version files and library specific changelog. diff --git a/.generator/test_cli.py b/.generator/test_cli.py index cf43ccd0745c..5e8b8c043e21 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -16,6 +16,7 @@ import logging import os import subprocess +import unittest.mock from unittest.mock import MagicMock, mock_open import pytest @@ -34,9 +35,12 @@ _get_libraries_to_prepare_for_release, _locate_and_extract_artifact, _read_json_file, + _read_text_file, _run_individual_session, _run_nox_sessions, _run_post_processor, + _update_global_changelog, + _write_text_file, handle_build, handle_configure, handle_generate, @@ -461,7 +465,7 @@ def test_read_valid_json(mocker): assert result == {"key": "value"} -def test_file_not_found(mocker): +def test_json_file_not_found(mocker): """Tests behavior when the file does not exist.""" mocker.patch("builtins.open", side_effect=FileNotFoundError("No such file")) @@ -505,10 +509,11 @@ def test_get_libraries_to_prepare_for_release(mock_release_init_request_file): assert libraries_to_prep_for_release[0]["release_triggered"] -def test_handle_release_init_success(mock_release_init_request_file): +def test_handle_release_init_success(mocker, mock_release_init_request_file): """ Simply tests that `handle_release_init` runs without errors. """ + mocker.patch("cli._update_global_changelog", return_value=None) handle_release_init() @@ -518,3 +523,51 @@ def test_handle_release_init_fail(): """ with pytest.raises(ValueError): handle_release_init() + + +def test_read_valid_file(mocker): + """Tests reading a valid text file.""" + mock_content = "some text" + mocker.patch("builtins.open", mocker.mock_open(read_data=mock_content)) + result = _read_text_file("fake/path.txt") + assert result == "some text" + + +def test_text_file_not_found(mocker): + """Tests behavior when the file does not exist.""" + mocker.patch("builtins.open", side_effect=FileNotFoundError("No such file")) + + with pytest.raises(FileNotFoundError): + _read_text_file("non/existent/path.text") + + +def test_write_file(): + """Tests writing a text file. + See https://docs.python.org/3/library/unittest.mock.html#mock-open + """ + m = mock_open() + + with unittest.mock.patch("cli.open", m): + _write_text_file("fake_path.txt", "modified content") + + handle = m() + handle.write.assert_called_once_with("modified content") + + +def test_update_global_changelog(mocker, mock_release_init_request_file): + """Tests that the global changelog is updated + with the new version for a given library. + See https://docs.python.org/3/library/unittest.mock.html#mock-open + """ + m = mock_open() + request_data = _read_json_file(f"{LIBRARIAN_DIR}/{RELEASE_INIT_REQUEST_FILE}") + libraries = _get_libraries_to_prepare_for_release(request_data) + + with unittest.mock.patch("cli.open", m): + mocker.patch( + "cli._read_text_file", return_value="[google-cloud-language==1.2.2]" + ) + _update_global_changelog("source", "output", libraries) + + handle = m() + handle.write.assert_called_once_with("[google-cloud-language==1.2.3]") From 6e45c59e2c3fd7e3dc1c48757a3a38c5b7fb12f9 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 14:26:20 +0000 Subject: [PATCH 14/20] fix typo --- .generator/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.generator/cli.py b/.generator/cli.py index b68583fac563..ee97deccc3b7 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -65,7 +65,7 @@ def _read_text_file(path: str) -> str: return f.read() -def _write_text_file(path: str, updated_content: str) -> str: +def _write_text_file(path: str, updated_content: str): """Helper function that reads a text file path and returns the content. Args: From 289382e1eae0dffeade8a97a58c672726ace3052 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 14:27:44 +0000 Subject: [PATCH 15/20] typo --- .generator/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index ee97deccc3b7..050d87719986 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -66,10 +66,10 @@ def _read_text_file(path: str) -> str: def _write_text_file(path: str, updated_content: str): - """Helper function that reads a text file path and returns the content. + """Helper function that writes a text file path with the given content. Args: - path(str): The file path to read. + path(str): The file path to write. updated_content(str): The contents to write to the file. Returns: From 7fe73bec24132f29d8b6ed7c66300bf2859fd080 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 17:05:02 +0000 Subject: [PATCH 16/20] chore: add ability to update library specific version files in release-init command --- .generator/cli.py | 78 +++++++++++++++++++++++++++++++++++++++-- .generator/test_cli.py | 79 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index 050d87719986..47d66779f998 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -102,6 +102,26 @@ def _read_json_file(path: str) -> Dict: return json.load(f) +def _write_json_file(path: str, updated_content: Dict): + """Helper function that writes a json file with the given dictionary. + + Args: + path(str): The file path to write. + updated_content(Dict): The dictionary to write + + Returns: + None + + Raises: + FileNotFoundError: If the file is not found at the specified path. + IOError: If there is an issue writing the file. + """ + + with open(path, "w") as f: + json.dump(updated_content, f, indent=2) + f.write("\n") + + def handle_configure(): # TODO(https://github.com/googleapis/librarian/issues/466): Implement configure command and update docstring. logger.info("'configure' command executed.") @@ -518,6 +538,57 @@ def replace_version_in_changelog(content): _write_text_file(output_path, updated_content) +def _update_version_for_library( + repo: str, output: str, path_to_library: str, version: str +): + """Updates the version string in various files for a library, such as + gapic_version.py and snippet_metadata.json files. + + Args: + repo(str): This directory will contain all directories that make up a + library, the .librarian folder, and any global file declared in + the config.yaml. + output(str): Path to the directory in the container where modified + code should be placed. + path_to_library(str): Relative path to the library to update + version(str): The new version of the library + """ + + # Find and update gapic_version.py files + gapic_version_files = Path(f"{repo}/{path_to_library}").rglob("**/gapic_version.py") + for version_file in gapic_version_files: + + def process_version_file(content): + pattern = r"(__version__\s*=\s*[\"'])([^\"']+)([\"'].*)" + replacement_string = f"\\g<1>{version}\\g<3>" + new_content, num_replacements = re.subn( + pattern, replacement_string, content + ) + print(content) + if num_replacements == 0: + raise ValueError( + f"Could not find version string in {version_file}. File was not modified." + ) + return new_content + + updated_content = process_version_file(_read_text_file(version_file)) + output_path = f"{output}/{version_file.relative_to(repo)}" + _write_text_file(output_path, updated_content) + + # Find and update snippet_metadata.json files + snippet_metadata_files = Path(f"{repo}/{path_to_library}").rglob( + "samples/**/*.json" + ) + for metadata_file in snippet_metadata_files: + output_path = f"{output}/{metadata_file.relative_to(repo)}" + os.makedirs(Path(output_path).parent, exist_ok=True) + shutil.copy(metadata_file, output_path) + + metadata_contents = _read_json_file(metadata_file) + metadata_contents["clientLibrary"]["version"] = version + _write_json_file(output_path, metadata_contents) + + def handle_release_init( librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR ): @@ -556,8 +627,11 @@ def handle_release_init( # Prepare the release for each library by updating the # library specific version files and library specific changelog. for library_release_data in libraries_to_prep_for_release: - # TODO(https://github.com/googleapis/google-cloud-python/pull/14350): - # Update library specific version files. + version = library_release_data["version"] + package_name = library_release_data["id"] + path_to_library = f"packages/{package_name}" + _update_version_for_library(repo, output, path_to_library, version) + # TODO(https://github.com/googleapis/google-cloud-python/pull/14351): # Conditionally update the library specific CHANGELOG if there is a change. pass diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 5e8b8c043e21..13e4e65758bf 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -15,12 +15,12 @@ import json import logging import os +import pathlib import subprocess import unittest.mock from unittest.mock import MagicMock, mock_open import pytest - from cli import ( GENERATE_REQUEST_FILE, BUILD_REQUEST_FILE, @@ -40,6 +40,8 @@ _run_nox_sessions, _run_post_processor, _update_global_changelog, + _update_version_for_library, + _write_json_file, _write_text_file, handle_build, handle_configure, @@ -514,6 +516,7 @@ def test_handle_release_init_success(mocker, mock_release_init_request_file): Simply tests that `handle_release_init` runs without errors. """ mocker.patch("cli._update_global_changelog", return_value=None) + mocker.patch("cli._update_version_for_library", return_value=None) handle_release_init() @@ -541,7 +544,7 @@ def test_text_file_not_found(mocker): _read_text_file("non/existent/path.text") -def test_write_file(): +def test_write_text_file(): """Tests writing a text file. See https://docs.python.org/3/library/unittest.mock.html#mock-open """ @@ -554,6 +557,31 @@ def test_write_file(): handle.write.assert_called_once_with("modified content") +def test_write_json_file(): + """Tests writing a json file. + See https://docs.python.org/3/library/unittest.mock.html#mock-open + """ + m = mock_open() + + expected_dict = {"name": "call me json"} + + with unittest.mock.patch("cli.open", m): + _write_json_file("fake_path.json", expected_dict) + + handle = m() + # Get all the arguments passed to the mock's write method + # and join them into a single string. + written_content = "".join( + [call.args[0] for call in handle.write.call_args_list] + ) + + # Create the expected output string with the correct formatting. + expected_output = json.dumps(expected_dict, indent=2) + "\n" + + # Assert that the content written to the mock file matches the expected output. + assert written_content == expected_output + + def test_update_global_changelog(mocker, mock_release_init_request_file): """Tests that the global changelog is updated with the new version for a given library. @@ -571,3 +599,50 @@ def test_update_global_changelog(mocker, mock_release_init_request_file): handle = m() handle.write.assert_called_once_with("[google-cloud-language==1.2.3]") + + +def test_update_version_for_library_success(mocker): + m = mock_open() + + mock_rglob = mocker.patch( + "pathlib.Path.rglob", return_value=[pathlib.Path("repo/gapic_version.py")] + ) + mock_shutil_copy = mocker.patch("shutil.copy") + mock_content = '__version__ = "1.2.2"' + mock_json_metadata = {"clientLibrary": {"version": "0.1.0"}} + + with unittest.mock.patch("cli.open", m): + mocker.patch("cli._read_text_file", return_value=mock_content) + mocker.patch("cli._read_json_file", return_value=mock_json_metadata) + _update_version_for_library( + "repo", "output", "packages/google-cloud-language", "1.2.3" + ) + + handle = m() + assert handle.write.call_args_list[0].args[0] == '__version__ = "1.2.3"' + + # Get all the arguments passed to the mock's write method + # and join them into a single string. + written_content = "".join( + [call.args[0] for call in handle.write.call_args_list[1:]] + ) + # Create the expected output string with the correct formatting. + assert ( + written_content + == '{\n "clientLibrary": {\n "version": "1.2.3"\n }\n}\n' + ) + + +def test_update_version_for_library_failure(mocker): + m = mock_open() + + mock_rglob = mocker.patch( + "pathlib.Path.rglob", return_value=[pathlib.Path("repo/gapic_version.py")] + ) + mock_content = "not found" + with pytest.raises(ValueError): + with unittest.mock.patch("cli.open", m): + mocker.patch("cli._read_text_file", return_value=mock_content) + _update_version_for_library( + "repo", "output", "packages/google-cloud-language", "1.2.3" + ) From d8eda94565ae3dcf5767185cffc96e131e1f662b Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Wed, 3 Sep 2025 18:07:35 +0000 Subject: [PATCH 17/20] chore: add ability to update library specific changelog in release-init --- .generator/cli.py | 113 +++++++++++++++++++++++++++++++++++++++-- .generator/test_cli.py | 109 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 3 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index 47d66779f998..8464b0197b94 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -14,6 +14,7 @@ import argparse import glob +import itertools import json import logging import os @@ -21,6 +22,8 @@ import shutil import subprocess import sys +import yaml +from datetime import datetime from pathlib import Path from typing import Dict, List @@ -39,6 +42,7 @@ BUILD_REQUEST_FILE = "build-request.json" GENERATE_REQUEST_FILE = "generate-request.json" RELEASE_INIT_REQUEST_FILE = "release-init-request.json" +STATE_YAML_FILE = "state.yaml" INPUT_DIR = "input" LIBRARIAN_DIR = "librarian" @@ -589,6 +593,100 @@ def process_version_file(content): _write_json_file(output_path, metadata_contents) +def _get_previous_version(package_name: str, librarian: str) -> str: + """Gets the previous version of the library from state.yaml. + + Args: + package_name(str): name of the package. + librarian(str): Path to the directory in the container which contains + the `state.yaml` file. + + Returns: + str: The version for a given library in state.yaml + """ + state_yaml_path = f"{librarian}/{STATE_YAML_FILE}" + + with open(state_yaml_path, "r") as state_yaml_file: + state_yaml = yaml.safe_load(state_yaml_file) + for library in state_yaml.get("libraries", []): + if library.get("id") == package_name: + return library.get("version") + + raise ValueError( + f"Could not determine previous version for {package_name} from state.yaml" + ) + + +def _update_changelog_for_library( + repo: str, + output: str, + library_changes: List[Dict], + version: str, + previous_version: str, + package_name: str, +): + """Prepends a new release entry with multiple, grouped changes to a changelog. + + Args: + repo(str): This directory will contain all directories that make up a + library, the .librarian folder, and any global file declared in + the config.yaml. + output(str): Path to the directory in the container where modified + code should be placed. + library_changes(List[Dict]): List of dictionaries containing the changes + for a given library + version(str): The desired version + previous_version(str): The version in state.yaml for a given package + package_name(str): The name of the package where the changelog should + be updated. + """ + + def process_changelog(content): + repo_url = "https://github.com/googleapis/google-cloud-python" + current_date = datetime.now().strftime("%Y-%m-%d") + + # Create the main version header + version_header = ( + f"## [{version}]({repo_url}/compare/{package_name}-v{previous_version}" + f"...{package_name}-v{version}) ({current_date})" + ) + entry_parts = [version_header] + + # Group changes by type (e.g., feat, fix, docs) + library_changes.sort(key=lambda x: x["type"]) + grouped_changes = itertools.groupby(library_changes, key=lambda x: x["type"]) + + for change_type, changes in grouped_changes: + # We only care about feat, fix, docs + if change_type.replace("!", "") in ["feat", "fix", "docs"]: + entry_parts.append( + f"\n\n### {change_type.capitalize().replace('!', '')}\n" + ) + for change in changes: + commit_link = f"([{change['source_commit_hash']}]({repo_url}/commit/{change['source_commit_hash']}))" + entry_parts.append(f"* {change['subject']} {commit_link}") + + new_entry_text = "\n".join(entry_parts) + anchor_pattern = re.compile( + r"(\[1\]: https://pypi\.org/project/google-cloud-language/#history)", + re.MULTILINE, + ) + replacement_text = f"\\g<1>\n\n{new_entry_text}" + updated_content, num_subs = anchor_pattern.subn( + replacement_text, content, count=1 + ) + + if num_subs == 0: + raise ValueError("Changelog anchor '[1]: ...#history' not found.") + + return updated_content + + source_path = f"{repo}/packages/{package_name}/CHANGELOG.md" + output_path = f"{output}/packages/{package_name}/CHANGELOG.md" + updated_content = process_changelog(_read_text_file(source_path)) + _write_text_file(output_path, updated_content) + + def handle_release_init( librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR ): @@ -629,12 +727,21 @@ def handle_release_init( for library_release_data in libraries_to_prep_for_release: version = library_release_data["version"] package_name = library_release_data["id"] + library_changes = library_release_data["changes"] path_to_library = f"packages/{package_name}" _update_version_for_library(repo, output, path_to_library, version) - # TODO(https://github.com/googleapis/google-cloud-python/pull/14351): - # Conditionally update the library specific CHANGELOG if there is a change. - pass + # Get previous version from state.yaml + previous_version = _get_previous_version(package_name, librarian) + if previous_version != version: + _update_changelog_for_library( + repo, + output, + library_changes, + version, + previous_version, + package_name, + ) except Exception as e: raise ValueError(f"Release init failed: {e}") from e diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 13e4e65758bf..d77278acd889 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -17,6 +17,7 @@ import os import pathlib import subprocess +import yaml import unittest.mock from unittest.mock import MagicMock, mock_open @@ -25,6 +26,7 @@ GENERATE_REQUEST_FILE, BUILD_REQUEST_FILE, RELEASE_INIT_REQUEST_FILE, + STATE_YAML_FILE, LIBRARIAN_DIR, REPO_DIR, _build_bazel_target, @@ -33,12 +35,14 @@ _determine_bazel_rule, _get_library_id, _get_libraries_to_prepare_for_release, + _get_previous_version, _locate_and_extract_artifact, _read_json_file, _read_text_file, _run_individual_session, _run_nox_sessions, _run_post_processor, + _update_changelog_for_library, _update_global_changelog, _update_version_for_library, _write_json_file, @@ -135,6 +139,25 @@ def mock_release_init_request_file(tmp_path, monkeypatch): return request_file +@pytest.fixture +def mock_state_file(tmp_path, monkeypatch): + """Creates the state file at the correct path inside a temp dir.""" + # Create the path as expected by the script: .librarian/state.yaml + request_path = f"{LIBRARIAN_DIR}/{STATE_YAML_FILE}" + request_dir = tmp_path / os.path.dirname(request_path) + request_dir.mkdir() + request_file = request_dir / os.path.basename(request_path) + + state_yaml_contents = { + "libraries": [{"id": "google-cloud-language", "version": "1.2.3"}] + } + request_file.write_text(yaml.dump(state_yaml_contents)) + + # Change the current working directory to the temp path for the test. + monkeypatch.chdir(tmp_path) + return request_file + + def test_get_library_id_success(): """Tests that _get_library_id returns the correct ID when present.""" request_data = {"id": "test-library", "name": "Test Library"} @@ -517,6 +540,8 @@ def test_handle_release_init_success(mocker, mock_release_init_request_file): """ mocker.patch("cli._update_global_changelog", return_value=None) mocker.patch("cli._update_version_for_library", return_value=None) + mocker.patch("cli._get_previous_version", return_value=None) + mocker.patch("cli._update_changelog_for_library", return_value=None) handle_release_init() @@ -646,3 +671,87 @@ def test_update_version_for_library_failure(mocker): _update_version_for_library( "repo", "output", "packages/google-cloud-language", "1.2.3" ) + + +def test_get_previous_version_success(mock_state_file): + """Test that the version can be retrieved from the state.yaml for a given library""" + previous_version = _get_previous_version("google-cloud-language", LIBRARIAN_DIR) + assert previous_version == "1.2.3" + + +def test_get_previous_version_failure(mock_state_file): + """Test that ValueError is raised when a library does not exist in state.yaml""" + with pytest.raises(ValueError): + _get_previous_version("google-cloud-does-not-exist", LIBRARIAN_DIR) + + +def test_update_changelog_for_library_success(mocker): + m = mock_open() + + mock_content = """# Changelog + +[PyPI History][1] + +[1]: https://pypi.org/project/google-cloud-language/#history + +## [2.17.2](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v2.17.1...google-cloud-language-v2.17.2) (2025-06-11) + +""" + + mock_changes = [ + { + "type": "feat", + "subject": "add new UpdateRepository API", + "body": "This adds the ability to update a repository's properties.", + "piper_cl_number": "786353207", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "docs", + "subject": "fix typo in BranchRule comment", + "body": "", + "piper_cl_number": "786353207", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, + ] + + with unittest.mock.patch("cli.open", m): + mocker.patch("cli._read_text_file", return_value=mock_content) + _update_changelog_for_library( + "repo", "output", mock_changes, "1.2.3", "1.2.2", "google-cloud-language" + ) + + +def test_update_changelog_for_library_failure(mocker): + m = mock_open() + + mock_content = """# Changelog""" + + mock_changes = [ + { + "type": "feat", + "subject": "add new UpdateRepository API", + "body": "This adds the ability to update a repository's properties.", + "piper_cl_number": "786353207", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "docs", + "subject": "fix typo in BranchRule comment", + "body": "", + "piper_cl_number": "786353207", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, + ] + + with pytest.raises(ValueError): + with unittest.mock.patch("cli.open", m): + mocker.patch("cli._read_text_file", return_value=mock_content) + _update_changelog_for_library( + "repo", + "output", + mock_changes, + "1.2.3", + "1.2.2", + "google-cloud-language", + ) From 8365905746b0daa0742c15abf17c606904c264d8 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Fri, 5 Sep 2025 15:56:10 +0000 Subject: [PATCH 18/20] refactor --- .generator/cli.py | 119 ++++++++++++++++++++++++++--------------- .generator/test_cli.py | 62 +++++++++++++++++++++ 2 files changed, 139 insertions(+), 42 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index a0e96c577d47..8c86c4831433 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -606,6 +606,75 @@ def _get_previous_version(package_name: str, librarian: str) -> str: ) +def _process_changelog( + content, library_changes, version, previous_version, package_name +): + """This function searches the given content for the anchor pattern + `[1]: https://pypi.org/project/{package_name}/#history` + and adds an entry in the following format: + + ## [{version}](https://github.com/googleapis/google-cloud-python/compare/{package_name}-v{previous_version}...{package_name}-v{version}) (YYYY-MM-DD) + + ### Documentation + + * Update import statement example in README ([868b006](https://github.com/googleapis/google-cloud-python/commit/868b0069baf1a4bf6705986e0b6885419b35cdcc)) + + Args: + content(str): The contents of an existing changelog. + library_changes(List[Dict]): List of dictionaries containing the changes + for a given library. + version(str): The new version of the library. + previous_version: The previous version of the library. + package_name(str): The name of the package where the changelog should + be updated. + + Raises: ValueError if the anchor pattern string could not be found in the given content + + Returns: A string with the modified content. + """ + repo_url = "https://github.com/googleapis/google-cloud-python" + current_date = datetime.now().strftime("%Y-%m-%d") + + # Create the main version header + version_header = ( + f"## [{version}]({repo_url}/compare/{package_name}-v{previous_version}" + f"...{package_name}-v{version}) ({current_date})" + ) + entry_parts = [version_header] + + # Group changes by type (e.g., feat, fix, docs) + library_changes.sort(key=lambda x: x["type"]) + grouped_changes = itertools.groupby(library_changes, key=lambda x: x["type"]) + + for change_type, changes in grouped_changes: + # We only care about feat, fix, docs + adjusted_change_type = change_type.replace("!", "") + if adjusted_change_type in ["feat", "fix", "docs"]: + if adjusted_change_type == "feat": + adjusted_change_type = "Features" + elif adjusted_change_type == "fix": + adjusted_change_type = "Bug Fixes" + else: + adjusted_change_type = "Documentation" + + entry_parts.append(f"\n\n### {adjusted_change_type}\n") + for change in changes: + commit_link = f"([{change['source_commit_hash']}]({repo_url}/commit/{change['source_commit_hash']}))" + entry_parts.append(f"* {change['subject']} {commit_link}") + + new_entry_text = "\n".join(entry_parts) + anchor_pattern = re.compile( + rf"(\[1\]: https://pypi\.org/project/{package_name}/#history)", + re.MULTILINE, + ) + replacement_text = f"\\g<1>\n\n{new_entry_text}" + updated_content, num_subs = anchor_pattern.subn(replacement_text, content, count=1) + if num_subs == 0: + raise ValueError("Changelog anchor '[1]: ...#history' not found.") + + return updated_content + + def _update_changelog_for_library( repo: str, output: str, @@ -614,7 +683,7 @@ def _update_changelog_for_library( previous_version: str, package_name: str, ): - """Prepends a new release entry with multiple, grouped changes to a changelog. + """Prepends a new release entry with multiple, grouped changes, to a changelog. Args: repo(str): This directory will contain all directories that make up a @@ -630,49 +699,15 @@ def _update_changelog_for_library( be updated. """ - def process_changelog(content): - repo_url = "https://github.com/googleapis/google-cloud-python" - current_date = datetime.now().strftime("%Y-%m-%d") - - # Create the main version header - version_header = ( - f"## [{version}]({repo_url}/compare/{package_name}-v{previous_version}" - f"...{package_name}-v{version}) ({current_date})" - ) - entry_parts = [version_header] - - # Group changes by type (e.g., feat, fix, docs) - library_changes.sort(key=lambda x: x["type"]) - grouped_changes = itertools.groupby(library_changes, key=lambda x: x["type"]) - - for change_type, changes in grouped_changes: - # We only care about feat, fix, docs - if change_type.replace("!", "") in ["feat", "fix", "docs"]: - entry_parts.append( - f"\n\n### {change_type.capitalize().replace('!', '')}\n" - ) - for change in changes: - commit_link = f"([{change['source_commit_hash']}]({repo_url}/commit/{change['source_commit_hash']}))" - entry_parts.append(f"* {change['subject']} {commit_link}") - - new_entry_text = "\n".join(entry_parts) - anchor_pattern = re.compile( - r"(\[1\]: https://pypi\.org/project/google-cloud-language/#history)", - re.MULTILINE, - ) - replacement_text = f"\\g<1>\n\n{new_entry_text}" - updated_content, num_subs = anchor_pattern.subn( - replacement_text, content, count=1 - ) - - if num_subs == 0: - raise ValueError("Changelog anchor '[1]: ...#history' not found.") - - return updated_content - source_path = f"{repo}/packages/{package_name}/CHANGELOG.md" output_path = f"{output}/packages/{package_name}/CHANGELOG.md" - updated_content = process_changelog(_read_text_file(source_path)) + updated_content = _process_changelog( + _read_text_file(source_path), + library_changes, + version, + previous_version, + package_name, + ) _write_text_file(output_path, updated_content) diff --git a/.generator/test_cli.py b/.generator/test_cli.py index 8081e2cfea6e..df23fe3979dc 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -37,6 +37,7 @@ _get_libraries_to_prepare_for_release, _get_previous_version, _locate_and_extract_artifact, + _process_changelog, _process_version_file, _read_json_file, _read_text_file, @@ -724,6 +725,66 @@ def test_update_changelog_for_library_success(mocker): ) +def test_process_changelog_success(): + """Tests that value error is raised if the changelog anchor string cannot be found""" + mock_content = """# Changelog\n[PyPI History][1]\n[1]: https://pypi.org/project/google-cloud-language/#history\n +## [1.2.2](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v1.2.1...google-cloud-language-v1.2.2) (2025-06-11)""" + expected_result = """# Changelog\n[PyPI History][1]\n[1]: https://pypi.org/project/google-cloud-language/#history\n +## [1.2.3](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v1.2.2...google-cloud-language-v1.2.3) (2025-09-05)\n\n +### Documentation\n +* fix typo in BranchRule comment ([9461532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/9461532e7d19c8d71709ec3b502e5d81340fb661))\n\n +### Features\n +* add new UpdateRepository API ([9461532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/9461532e7d19c8d71709ec3b502e5d81340fb661))\n\n +### Bug Fixes\n +* some fix ([1231532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/1231532e7d19c8d71709ec3b502e5d81340fb661)) +* another fix ([1241532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/1241532e7d19c8d71709ec3b502e5d81340fb661))\n +## [1.2.2](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v1.2.1...google-cloud-language-v1.2.2) (2025-06-11)""" + mock_changes = [ + { + "type": "feat", + "subject": "add new UpdateRepository API", + "body": "This adds the ability to update a repository's properties.", + "piper_cl_number": "786353207", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "fix", + "subject": "some fix", + "body": "", + "piper_cl_number": "786353208", + "source_commit_hash": "1231532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "fix", + "subject": "another fix", + "body": "", + "piper_cl_number": "786353209", + "source_commit_hash": "1241532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "docs", + "subject": "fix typo in BranchRule comment", + "body": "", + "piper_cl_number": "786353210", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, + ] + version = "1.2.3" + previous_version = "1.2.2" + package_name = "google-cloud-language" + + result = _process_changelog( + mock_content, mock_changes, version, previous_version, package_name + ) + assert result == expected_result + + +def test_process_changelog_failure(): + """Tests that value error is raised if the changelog anchor string cannot be found""" + with pytest.raises(ValueError): + _process_changelog("", [], "", "", "") + + def test_update_changelog_for_library_failure(mocker): m = mock_open() @@ -758,6 +819,7 @@ def test_update_changelog_for_library_failure(mocker): "google-cloud-language", ) + def test_process_version_file_success(): version_file_contents = '__version__ = "1.2.2"' new_version = "1.2.3" From 2f63be61af0580035ac384949fae441f23d359b2 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Mon, 8 Sep 2025 19:09:44 +0000 Subject: [PATCH 19/20] remove code duplication --- .generator/test_cli.py | 114 ++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 70 deletions(-) diff --git a/.generator/test_cli.py b/.generator/test_cli.py index df23fe3979dc..0f36095de0eb 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -19,6 +19,7 @@ import subprocess import yaml import unittest.mock +from datetime import datetime from unittest.mock import MagicMock, mock_open import pytest @@ -56,6 +57,38 @@ ) +_MOCK_LIBRARY_CHANGES = [ + { + "type": "feat", + "subject": "add new UpdateRepository API", + "body": "This adds the ability to update a repository's properties.", + "piper_cl_number": "786353207", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "fix", + "subject": "some fix", + "body": "", + "piper_cl_number": "786353208", + "source_commit_hash": "1231532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "fix", + "subject": "another fix", + "body": "", + "piper_cl_number": "786353209", + "source_commit_hash": "1241532e7d19c8d71709ec3b502e5d81340fb661", + }, + { + "type": "docs", + "subject": "fix typo in BranchRule comment", + "body": "", + "piper_cl_number": "786353210", + "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", + }, +] + + @pytest.fixture def mock_generate_request_file(tmp_path, monkeypatch): """Creates the mock request file at the correct path inside a temp dir.""" @@ -700,37 +733,25 @@ def test_update_changelog_for_library_success(mocker): ## [2.17.2](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v2.17.1...google-cloud-language-v2.17.2) (2025-06-11) """ - - mock_changes = [ - { - "type": "feat", - "subject": "add new UpdateRepository API", - "body": "This adds the ability to update a repository's properties.", - "piper_cl_number": "786353207", - "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", - }, - { - "type": "docs", - "subject": "fix typo in BranchRule comment", - "body": "", - "piper_cl_number": "786353207", - "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", - }, - ] - with unittest.mock.patch("cli.open", m): mocker.patch("cli._read_text_file", return_value=mock_content) _update_changelog_for_library( - "repo", "output", mock_changes, "1.2.3", "1.2.2", "google-cloud-language" + "repo", + "output", + _MOCK_LIBRARY_CHANGES, + "1.2.3", + "1.2.2", + "google-cloud-language", ) def test_process_changelog_success(): """Tests that value error is raised if the changelog anchor string cannot be found""" + current_date = datetime.now().strftime("%Y-%m-%d") mock_content = """# Changelog\n[PyPI History][1]\n[1]: https://pypi.org/project/google-cloud-language/#history\n ## [1.2.2](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v1.2.1...google-cloud-language-v1.2.2) (2025-06-11)""" - expected_result = """# Changelog\n[PyPI History][1]\n[1]: https://pypi.org/project/google-cloud-language/#history\n -## [1.2.3](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v1.2.2...google-cloud-language-v1.2.3) (2025-09-05)\n\n + expected_result = f"""# Changelog\n[PyPI History][1]\n[1]: https://pypi.org/project/google-cloud-language/#history\n +## [1.2.3](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v1.2.2...google-cloud-language-v1.2.3) ({current_date})\n\n ### Documentation\n * fix typo in BranchRule comment ([9461532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/9461532e7d19c8d71709ec3b502e5d81340fb661))\n\n ### Features\n @@ -739,42 +760,12 @@ def test_process_changelog_success(): * some fix ([1231532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/1231532e7d19c8d71709ec3b502e5d81340fb661)) * another fix ([1241532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/1241532e7d19c8d71709ec3b502e5d81340fb661))\n ## [1.2.2](https://github.com/googleapis/google-cloud-python/compare/google-cloud-language-v1.2.1...google-cloud-language-v1.2.2) (2025-06-11)""" - mock_changes = [ - { - "type": "feat", - "subject": "add new UpdateRepository API", - "body": "This adds the ability to update a repository's properties.", - "piper_cl_number": "786353207", - "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", - }, - { - "type": "fix", - "subject": "some fix", - "body": "", - "piper_cl_number": "786353208", - "source_commit_hash": "1231532e7d19c8d71709ec3b502e5d81340fb661", - }, - { - "type": "fix", - "subject": "another fix", - "body": "", - "piper_cl_number": "786353209", - "source_commit_hash": "1241532e7d19c8d71709ec3b502e5d81340fb661", - }, - { - "type": "docs", - "subject": "fix typo in BranchRule comment", - "body": "", - "piper_cl_number": "786353210", - "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", - }, - ] version = "1.2.3" previous_version = "1.2.2" package_name = "google-cloud-language" result = _process_changelog( - mock_content, mock_changes, version, previous_version, package_name + mock_content, _MOCK_LIBRARY_CHANGES, version, previous_version, package_name ) assert result == expected_result @@ -790,30 +781,13 @@ def test_update_changelog_for_library_failure(mocker): mock_content = """# Changelog""" - mock_changes = [ - { - "type": "feat", - "subject": "add new UpdateRepository API", - "body": "This adds the ability to update a repository's properties.", - "piper_cl_number": "786353207", - "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", - }, - { - "type": "docs", - "subject": "fix typo in BranchRule comment", - "body": "", - "piper_cl_number": "786353207", - "source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661", - }, - ] - with pytest.raises(ValueError): with unittest.mock.patch("cli.open", m): mocker.patch("cli._read_text_file", return_value=mock_content) _update_changelog_for_library( "repo", "output", - mock_changes, + _MOCK_LIBRARY_CHANGES, "1.2.3", "1.2.2", "google-cloud-language", From 4973613d71c30d0b8498bec45f1485edf179b3c5 Mon Sep 17 00:00:00 2001 From: Anthonios Partheniou Date: Mon, 8 Sep 2025 19:11:50 +0000 Subject: [PATCH 20/20] refactor --- .generator/cli.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.generator/cli.py b/.generator/cli.py index 8c86c4831433..df843c165a7c 100644 --- a/.generator/cli.py +++ b/.generator/cli.py @@ -649,15 +649,13 @@ def _process_changelog( for change_type, changes in grouped_changes: # We only care about feat, fix, docs adjusted_change_type = change_type.replace("!", "") + change_type_map = { + "feat": "Features", + "fix": "Bug Fixes", + "docs": "Documentation", + } if adjusted_change_type in ["feat", "fix", "docs"]: - if adjusted_change_type == "feat": - adjusted_change_type = "Features" - elif adjusted_change_type == "fix": - adjusted_change_type = "Bug Fixes" - else: - adjusted_change_type = "Documentation" - - entry_parts.append(f"\n\n### {adjusted_change_type}\n") + entry_parts.append(f"\n\n### {change_type_map[adjusted_change_type]}\n") for change in changes: commit_link = f"([{change['source_commit_hash']}]({repo_url}/commit/{change['source_commit_hash']}))" entry_parts.append(f"* {change['subject']} {commit_link}")