Skip to content

Commit 6405a6e

Browse files
authored
chore: add ability to update library specific changelog in release-init (#14353)
This PR builds on top of #14350 and adds the ability to update the client library changelog using the `librarian release init` command Towards googleapis/librarian#886
1 parent 5f36d76 commit 6405a6e

File tree

2 files changed

+287
-4
lines changed

2 files changed

+287
-4
lines changed

.generator/cli.py

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,16 @@
1414

1515
import argparse
1616
import glob
17+
import itertools
1718
import json
1819
import logging
1920
import os
2021
import re
2122
import shutil
2223
import subprocess
2324
import sys
25+
import yaml
26+
from datetime import datetime
2427
from pathlib import Path
2528
from typing import Dict, List
2629

@@ -39,6 +42,7 @@
3942
BUILD_REQUEST_FILE = "build-request.json"
4043
GENERATE_REQUEST_FILE = "generate-request.json"
4144
RELEASE_INIT_REQUEST_FILE = "release-init-request.json"
45+
STATE_YAML_FILE = "state.yaml"
4246

4347
INPUT_DIR = "input"
4448
LIBRARIAN_DIR = "librarian"
@@ -578,6 +582,133 @@ def _update_version_for_library(
578582
_write_json_file(output_path, metadata_contents)
579583

580584

585+
def _get_previous_version(package_name: str, librarian: str) -> str:
586+
"""Gets the previous version of the library from state.yaml.
587+
588+
Args:
589+
package_name(str): name of the package.
590+
librarian(str): Path to the directory in the container which contains
591+
the `state.yaml` file.
592+
593+
Returns:
594+
str: The version for a given library in state.yaml
595+
"""
596+
state_yaml_path = f"{librarian}/{STATE_YAML_FILE}"
597+
598+
with open(state_yaml_path, "r") as state_yaml_file:
599+
state_yaml = yaml.safe_load(state_yaml_file)
600+
for library in state_yaml.get("libraries", []):
601+
if library.get("id") == package_name:
602+
return library.get("version")
603+
604+
raise ValueError(
605+
f"Could not determine previous version for {package_name} from state.yaml"
606+
)
607+
608+
609+
def _process_changelog(
610+
content, library_changes, version, previous_version, package_name
611+
):
612+
"""This function searches the given content for the anchor pattern
613+
`[1]: https://pypi.org/project/{package_name}/#history`
614+
and adds an entry in the following format:
615+
616+
## [{version}](https://github.com/googleapis/google-cloud-python/compare/{package_name}-v{previous_version}...{package_name}-v{version}) (YYYY-MM-DD)
617+
618+
### Documentation
619+
620+
* Update import statement example in README ([868b006](https://github.com/googleapis/google-cloud-python/commit/868b0069baf1a4bf6705986e0b6885419b35cdcc))
621+
622+
Args:
623+
content(str): The contents of an existing changelog.
624+
library_changes(List[Dict]): List of dictionaries containing the changes
625+
for a given library.
626+
version(str): The new version of the library.
627+
previous_version: The previous version of the library.
628+
package_name(str): The name of the package where the changelog should
629+
be updated.
630+
631+
Raises: ValueError if the anchor pattern string could not be found in the given content
632+
633+
Returns: A string with the modified content.
634+
"""
635+
repo_url = "https://github.com/googleapis/google-cloud-python"
636+
current_date = datetime.now().strftime("%Y-%m-%d")
637+
638+
# Create the main version header
639+
version_header = (
640+
f"## [{version}]({repo_url}/compare/{package_name}-v{previous_version}"
641+
f"...{package_name}-v{version}) ({current_date})"
642+
)
643+
entry_parts = [version_header]
644+
645+
# Group changes by type (e.g., feat, fix, docs)
646+
library_changes.sort(key=lambda x: x["type"])
647+
grouped_changes = itertools.groupby(library_changes, key=lambda x: x["type"])
648+
649+
for change_type, changes in grouped_changes:
650+
# We only care about feat, fix, docs
651+
adjusted_change_type = change_type.replace("!", "")
652+
change_type_map = {
653+
"feat": "Features",
654+
"fix": "Bug Fixes",
655+
"docs": "Documentation",
656+
}
657+
if adjusted_change_type in ["feat", "fix", "docs"]:
658+
entry_parts.append(f"\n\n### {change_type_map[adjusted_change_type]}\n")
659+
for change in changes:
660+
commit_link = f"([{change['source_commit_hash']}]({repo_url}/commit/{change['source_commit_hash']}))"
661+
entry_parts.append(f"* {change['subject']} {commit_link}")
662+
663+
new_entry_text = "\n".join(entry_parts)
664+
anchor_pattern = re.compile(
665+
rf"(\[1\]: https://pypi\.org/project/{package_name}/#history)",
666+
re.MULTILINE,
667+
)
668+
replacement_text = f"\\g<1>\n\n{new_entry_text}"
669+
updated_content, num_subs = anchor_pattern.subn(replacement_text, content, count=1)
670+
if num_subs == 0:
671+
raise ValueError("Changelog anchor '[1]: ...#history' not found.")
672+
673+
return updated_content
674+
675+
676+
def _update_changelog_for_library(
677+
repo: str,
678+
output: str,
679+
library_changes: List[Dict],
680+
version: str,
681+
previous_version: str,
682+
package_name: str,
683+
):
684+
"""Prepends a new release entry with multiple, grouped changes, to a changelog.
685+
686+
Args:
687+
repo(str): This directory will contain all directories that make up a
688+
library, the .librarian folder, and any global file declared in
689+
the config.yaml.
690+
output(str): Path to the directory in the container where modified
691+
code should be placed.
692+
library_changes(List[Dict]): List of dictionaries containing the changes
693+
for a given library
694+
version(str): The desired version
695+
previous_version(str): The version in state.yaml for a given package
696+
package_name(str): The name of the package where the changelog should
697+
be updated.
698+
"""
699+
700+
source_path = f"{repo}/packages/{package_name}/CHANGELOG.md"
701+
output_path = f"{output}/packages/{package_name}/CHANGELOG.md"
702+
updated_content = _process_changelog(
703+
_read_text_file(source_path),
704+
library_changes,
705+
version,
706+
previous_version,
707+
package_name,
708+
)
709+
_write_text_file(output_path, updated_content)
710+
711+
581712
def handle_release_init(
582713
librarian: str = LIBRARIAN_DIR, repo: str = REPO_DIR, output: str = OUTPUT_DIR
583714
):
@@ -618,13 +749,21 @@ def handle_release_init(
618749
for library_release_data in libraries_to_prep_for_release:
619750
version = library_release_data["version"]
620751
package_name = library_release_data["id"]
752+
library_changes = library_release_data["changes"]
621753
path_to_library = f"packages/{package_name}"
622754
_update_version_for_library(repo, output, path_to_library, version)
623755

624-
# Update library specific version files.
625-
# TODO(https://github.com/googleapis/google-cloud-python/pull/14353):
626-
# Conditionally update the library specific CHANGELOG if there is a change.
627-
pass
756+
# Get previous version from state.yaml
757+
previous_version = _get_previous_version(package_name, librarian)
758+
if previous_version != version:
759+
_update_changelog_for_library(
760+
repo,
761+
output,
762+
library_changes,
763+
version,
764+
previous_version,
765+
package_name,
766+
)
628767

629768
except Exception as e:
630769
raise ValueError(f"Release init failed: {e}") from e

.generator/test_cli.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@
1717
import os
1818
import pathlib
1919
import subprocess
20+
import yaml
2021
import unittest.mock
22+
from datetime import datetime
2123
from unittest.mock import MagicMock, mock_open
2224

2325
import pytest
2426
from cli import (
2527
GENERATE_REQUEST_FILE,
2628
BUILD_REQUEST_FILE,
2729
RELEASE_INIT_REQUEST_FILE,
30+
STATE_YAML_FILE,
2831
LIBRARIAN_DIR,
2932
REPO_DIR,
3033
_build_bazel_target,
@@ -33,13 +36,16 @@
3336
_determine_bazel_rule,
3437
_get_library_id,
3538
_get_libraries_to_prepare_for_release,
39+
_get_previous_version,
3640
_locate_and_extract_artifact,
41+
_process_changelog,
3742
_process_version_file,
3843
_read_json_file,
3944
_read_text_file,
4045
_run_individual_session,
4146
_run_nox_sessions,
4247
_run_post_processor,
48+
_update_changelog_for_library,
4349
_update_global_changelog,
4450
_update_version_for_library,
4551
_write_json_file,
@@ -51,6 +57,38 @@
5157
)
5258

5359

60+
_MOCK_LIBRARY_CHANGES = [
61+
{
62+
"type": "feat",
63+
"subject": "add new UpdateRepository API",
64+
"body": "This adds the ability to update a repository's properties.",
65+
"piper_cl_number": "786353207",
66+
"source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661",
67+
},
68+
{
69+
"type": "fix",
70+
"subject": "some fix",
71+
"body": "",
72+
"piper_cl_number": "786353208",
73+
"source_commit_hash": "1231532e7d19c8d71709ec3b502e5d81340fb661",
74+
},
75+
{
76+
"type": "fix",
77+
"subject": "another fix",
78+
"body": "",
79+
"piper_cl_number": "786353209",
80+
"source_commit_hash": "1241532e7d19c8d71709ec3b502e5d81340fb661",
81+
},
82+
{
83+
"type": "docs",
84+
"subject": "fix typo in BranchRule comment",
85+
"body": "",
86+
"piper_cl_number": "786353210",
87+
"source_commit_hash": "9461532e7d19c8d71709ec3b502e5d81340fb661",
88+
},
89+
]
90+
91+
5492
@pytest.fixture
5593
def mock_generate_request_file(tmp_path, monkeypatch):
5694
"""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):
136174
return request_file
137175

138176

177+
@pytest.fixture
178+
def mock_state_file(tmp_path, monkeypatch):
179+
"""Creates the state file at the correct path inside a temp dir."""
180+
# Create the path as expected by the script: .librarian/state.yaml
181+
request_path = f"{LIBRARIAN_DIR}/{STATE_YAML_FILE}"
182+
request_dir = tmp_path / os.path.dirname(request_path)
183+
request_dir.mkdir()
184+
request_file = request_dir / os.path.basename(request_path)
185+
186+
state_yaml_contents = {
187+
"libraries": [{"id": "google-cloud-language", "version": "1.2.3"}]
188+
}
189+
request_file.write_text(yaml.dump(state_yaml_contents))
190+
191+
# Change the current working directory to the temp path for the test.
192+
monkeypatch.chdir(tmp_path)
193+
return request_file
194+
195+
139196
def test_get_library_id_success():
140197
"""Tests that _get_library_id returns the correct ID when present."""
141198
request_data = {"id": "test-library", "name": "Test Library"}
@@ -518,6 +575,8 @@ def test_handle_release_init_success(mocker, mock_release_init_request_file):
518575
"""
519576
mocker.patch("cli._update_global_changelog", return_value=None)
520577
mocker.patch("cli._update_version_for_library", return_value=None)
578+
mocker.patch("cli._get_previous_version", return_value=None)
579+
mocker.patch("cli._update_changelog_for_library", return_value=None)
521580
handle_release_init()
522581

523582

@@ -650,6 +709,91 @@ def test_update_version_for_library_failure(mocker):
650709
)
651710

652711

712+
def test_get_previous_version_success(mock_state_file):
713+
"""Test that the version can be retrieved from the state.yaml for a given library"""
714+
previous_version = _get_previous_version("google-cloud-language", LIBRARIAN_DIR)
715+
assert previous_version == "1.2.3"
716+
717+
718+
def test_get_previous_version_failure(mock_state_file):
719+
"""Test that ValueError is raised when a library does not exist in state.yaml"""
720+
with pytest.raises(ValueError):
721+
_get_previous_version("google-cloud-does-not-exist", LIBRARIAN_DIR)
722+
723+
724+
def test_update_changelog_for_library_success(mocker):
725+
m = mock_open()
726+
727+
mock_content = """# Changelog
728+
729+
[PyPI History][1]
730+
731+
[1]: https://pypi.org/project/google-cloud-language/#history
732+
733+
## [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)
734+
735+
"""
736+
with unittest.mock.patch("cli.open", m):
737+
mocker.patch("cli._read_text_file", return_value=mock_content)
738+
_update_changelog_for_library(
739+
"repo",
740+
"output",
741+
_MOCK_LIBRARY_CHANGES,
742+
"1.2.3",
743+
"1.2.2",
744+
"google-cloud-language",
745+
)
746+
747+
748+
def test_process_changelog_success():
749+
"""Tests that value error is raised if the changelog anchor string cannot be found"""
750+
current_date = datetime.now().strftime("%Y-%m-%d")
751+
mock_content = """# Changelog\n[PyPI History][1]\n[1]: https://pypi.org/project/google-cloud-language/#history\n
752+
## [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)"""
753+
expected_result = f"""# Changelog\n[PyPI History][1]\n[1]: https://pypi.org/project/google-cloud-language/#history\n
754+
## [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
755+
### Documentation\n
756+
* fix typo in BranchRule comment ([9461532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/9461532e7d19c8d71709ec3b502e5d81340fb661))\n\n
757+
### Features\n
758+
* add new UpdateRepository API ([9461532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/9461532e7d19c8d71709ec3b502e5d81340fb661))\n\n
759+
### Bug Fixes\n
760+
* some fix ([1231532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/1231532e7d19c8d71709ec3b502e5d81340fb661))
761+
* another fix ([1241532e7d19c8d71709ec3b502e5d81340fb661](https://github.com/googleapis/google-cloud-python/commit/1241532e7d19c8d71709ec3b502e5d81340fb661))\n
762+
## [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)"""
763+
version = "1.2.3"
764+
previous_version = "1.2.2"
765+
package_name = "google-cloud-language"
766+
767+
result = _process_changelog(
768+
mock_content, _MOCK_LIBRARY_CHANGES, version, previous_version, package_name
769+
)
770+
assert result == expected_result
771+
772+
773+
def test_process_changelog_failure():
774+
"""Tests that value error is raised if the changelog anchor string cannot be found"""
775+
with pytest.raises(ValueError):
776+
_process_changelog("", [], "", "", "")
777+
778+
779+
def test_update_changelog_for_library_failure(mocker):
780+
m = mock_open()
781+
782+
mock_content = """# Changelog"""
783+
784+
with pytest.raises(ValueError):
785+
with unittest.mock.patch("cli.open", m):
786+
mocker.patch("cli._read_text_file", return_value=mock_content)
787+
_update_changelog_for_library(
788+
"repo",
789+
"output",
790+
_MOCK_LIBRARY_CHANGES,
791+
"1.2.3",
792+
"1.2.2",
793+
"google-cloud-language",
794+
)
795+
796+
653797
def test_process_version_file_success():
654798
version_file_contents = '__version__ = "1.2.2"'
655799
new_version = "1.2.3"

0 commit comments

Comments
 (0)