Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 143 additions & 4 deletions .generator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@

import argparse
import glob
import itertools
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import yaml
from datetime import datetime
from pathlib import Path
from typing import Dict, List

Expand All @@ -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"
Expand Down Expand Up @@ -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
):
Expand Down Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions .generator/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@
import os
import pathlib
import subprocess
import yaml
import unittest.mock
from datetime import datetime
from unittest.mock import MagicMock, mock_open

import pytest
from cli import (
GENERATE_REQUEST_FILE,
BUILD_REQUEST_FILE,
RELEASE_INIT_REQUEST_FILE,
STATE_YAML_FILE,
LIBRARIAN_DIR,
REPO_DIR,
_build_bazel_target,
Expand All @@ -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,
Expand All @@ -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."""
Expand Down Expand Up @@ -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"}
Expand Down Expand Up @@ -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()


Expand Down Expand Up @@ -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"
Expand Down
Loading