diff --git a/.generator/cli.py b/.generator/cli.py index 76bcec9bd806..df843c165a7c 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" @@ -578,6 +582,133 @@ def _update_version_for_library( _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 _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("!", "") + change_type_map = { + "feat": "Features", + "fix": "Bug Fixes", + "docs": "Documentation", + } + if adjusted_change_type in ["feat", "fix", "docs"]: + 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}") + + 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, + 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. + """ + + 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), + library_changes, + version, + previous_version, + package_name, + ) + _write_text_file(output_path, updated_content) + + def handle_release_init( librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR ): @@ -618,13 +749,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) - # Update library specific version files. - # TODO(https://github.com/googleapis/google-cloud-python/pull/14353): - # 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 30e056a12aba..0f36095de0eb 100644 --- a/.generator/test_cli.py +++ b/.generator/test_cli.py @@ -17,7 +17,9 @@ import os import pathlib import subprocess +import yaml import unittest.mock +from datetime import datetime from unittest.mock import MagicMock, mock_open import pytest @@ -25,6 +27,7 @@ GENERATE_REQUEST_FILE, BUILD_REQUEST_FILE, RELEASE_INIT_REQUEST_FILE, + STATE_YAML_FILE, LIBRARIAN_DIR, REPO_DIR, _build_bazel_target, @@ -33,13 +36,16 @@ _determine_bazel_rule, _get_library_id, _get_libraries_to_prepare_for_release, + _get_previous_version, _locate_and_extract_artifact, + _process_changelog, _process_version_file, _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, @@ -51,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.""" @@ -136,6 +174,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"} @@ -518,6 +575,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() @@ -650,6 +709,91 @@ def test_update_version_for_library_failure(mocker): ) +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) + +""" + with unittest.mock.patch("cli.open", m): + mocker.patch("cli._read_text_file", return_value=mock_content) + _update_changelog_for_library( + "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 = 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 +* 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)""" + version = "1.2.3" + previous_version = "1.2.2" + package_name = "google-cloud-language" + + result = _process_changelog( + mock_content, _MOCK_LIBRARY_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() + + mock_content = """# Changelog""" + + 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_LIBRARY_CHANGES, + "1.2.3", + "1.2.2", + "google-cloud-language", + ) + + def test_process_version_file_success(): version_file_contents = '__version__ = "1.2.2"' new_version = "1.2.3"