Skip to content

Commit 72bd08d

Browse files
Copilotl0lawrence
andauthored
Add changelog command group to azpysdk CLI wrapping Chronus
Adds `azpysdk changelog {add,verify,create,status}` subcommands that delegate to `npx chronus` at the repository root, enabling developers to manage changelogs without leaving the azpysdk CLI. Agent-Logs-Url: https://github.com/Azure/azure-sdk-for-python/sessions/a188ee1e-f36a-4ca9-be34-654196dd5328 Co-authored-by: l0lawrence <100643745+l0lawrence@users.noreply.github.com>
1 parent 33ae926 commit 72bd08d

3 files changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import argparse
2+
import os
3+
import shutil
4+
import subprocess
5+
from typing import Optional, List
6+
7+
from .Check import Check, REPO_ROOT
8+
from ci_tools.logging import logger
9+
10+
11+
class changelog(Check):
12+
"""Manage changelogs with Chronus.
13+
14+
Wraps Chronus CLI commands (add, verify, create, status) so they can be
15+
invoked through the ``azpysdk`` CLI. Unlike most checks that operate on
16+
individual packages, changelog commands run at the **repository root**
17+
level via ``npx chronus``.
18+
"""
19+
20+
def __init__(self) -> None:
21+
super().__init__()
22+
23+
def register(
24+
self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None
25+
) -> None:
26+
"""Register the ``changelog`` command group.
27+
28+
The *parent_parsers* (common args like ``target``, ``--isolate``) are
29+
intentionally **not** used here because changelog commands operate at
30+
the repository level via Chronus, not on individual packages.
31+
"""
32+
p = subparsers.add_parser(
33+
"changelog",
34+
help="Manage changelogs with Chronus (add, verify, create, status)",
35+
)
36+
37+
changelog_sub = p.add_subparsers(title="changelog commands", dest="changelog_command")
38+
39+
# changelog add
40+
add_p = changelog_sub.add_parser("add", help="Add a chronus change entry for modified packages")
41+
add_p.add_argument(
42+
"package",
43+
nargs="?",
44+
default=None,
45+
help=(
46+
"Package path (e.g. sdk/storage/azure-storage-blob) to add an entry for. "
47+
"If omitted, chronus detects modified packages interactively."
48+
),
49+
)
50+
add_p.set_defaults(func=self._run_add)
51+
52+
# changelog verify
53+
verify_p = changelog_sub.add_parser("verify", help="Verify all modified packages have change entries")
54+
verify_p.set_defaults(func=self._run_verify)
55+
56+
# changelog create
57+
create_p = changelog_sub.add_parser(
58+
"create", help="Generate CHANGELOG.md from pending chronus entries"
59+
)
60+
create_p.set_defaults(func=self._run_create)
61+
62+
# changelog status
63+
status_p = changelog_sub.add_parser(
64+
"status", help="Show a summary of pending changes and resulting version bumps"
65+
)
66+
status_p.set_defaults(func=self._run_status)
67+
68+
# Default behaviour when no subcommand is given
69+
p.set_defaults(func=self._no_subcommand, _changelog_parser=p)
70+
71+
# ------------------------------------------------------------------
72+
# Internal helpers
73+
# ------------------------------------------------------------------
74+
75+
def _no_subcommand(self, args: argparse.Namespace) -> int:
76+
"""Print help when no changelog subcommand is provided."""
77+
args._changelog_parser.print_help()
78+
return 1
79+
80+
def _get_npx(self) -> str:
81+
"""Locate the ``npx`` executable on *PATH*."""
82+
npx = shutil.which("npx")
83+
if not npx:
84+
logger.error(
85+
"npx is not installed. Chronus requires Node.js. "
86+
"Please install Node.js (LTS) from https://nodejs.org/ and try again."
87+
)
88+
raise FileNotFoundError("npx not found on PATH")
89+
return npx
90+
91+
def _run_chronus(self, chronus_args: List[str]) -> int:
92+
"""Run a chronus command via ``npx`` from the repository root.
93+
94+
stdin/stdout/stderr are inherited so that interactive prompts
95+
(e.g. ``chronus add``) work transparently.
96+
"""
97+
npx = self._get_npx()
98+
cmd = [npx, "chronus"] + chronus_args
99+
logger.info(f"Running: {' '.join(cmd)}")
100+
return subprocess.call(cmd, cwd=REPO_ROOT)
101+
102+
# ------------------------------------------------------------------
103+
# Subcommand handlers
104+
# ------------------------------------------------------------------
105+
106+
def _run_add(self, args: argparse.Namespace) -> int:
107+
"""Run ``chronus add`` to interactively add a change entry."""
108+
chronus_args = ["add"]
109+
package = getattr(args, "package", None)
110+
if package:
111+
chronus_args.append(package)
112+
return self._run_chronus(chronus_args)
113+
114+
def _run_verify(self, args: argparse.Namespace) -> int:
115+
"""Run ``chronus verify`` to check for missing change entries."""
116+
return self._run_chronus(["verify"])
117+
118+
def _run_create(self, args: argparse.Namespace) -> int:
119+
"""Run ``chronus changelog`` to generate CHANGELOG.md files."""
120+
return self._run_chronus(["changelog"])
121+
122+
def _run_status(self, args: argparse.Namespace) -> int:
123+
"""Run ``chronus status`` to show pending changes."""
124+
return self._run_chronus(["status"])

eng/tools/azure-sdk-tools/azpysdk/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from .devtest import devtest
4242
from .optional import optional
4343
from .update_snippet import update_snippet
44+
from .changelog import changelog
4445

4546
from ci_tools.logging import configure_logging, logger
4647

@@ -117,6 +118,7 @@ def build_parser() -> argparse.ArgumentParser:
117118
devtest().register(subparsers, [common])
118119
optional().register(subparsers, [common])
119120
update_snippet().register(subparsers, [common])
121+
changelog().register(subparsers, [common])
120122

121123
return parser
122124

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Tests for the ``azpysdk changelog`` command group."""
2+
3+
import argparse
4+
from unittest.mock import patch, MagicMock
5+
6+
import pytest
7+
8+
from azpysdk.changelog import changelog
9+
10+
11+
# ---------------------------------------------------------------------------
12+
# Helper – build a minimal parser that includes the changelog subcommands
13+
# ---------------------------------------------------------------------------
14+
15+
def _build_parser():
16+
parser = argparse.ArgumentParser(prog="azpysdk")
17+
subparsers = parser.add_subparsers(title="commands", dest="command")
18+
changelog().register(subparsers)
19+
return parser
20+
21+
22+
# ---------------------------------------------------------------------------
23+
# Parser / registration tests
24+
# ---------------------------------------------------------------------------
25+
26+
27+
class TestChangelogRegistration:
28+
"""Verify that the changelog command is registered correctly."""
29+
30+
def test_changelog_subcommand_exists(self):
31+
parser = _build_parser()
32+
args = parser.parse_args(["changelog", "verify"])
33+
assert args.command == "changelog"
34+
assert args.changelog_command == "verify"
35+
36+
def test_changelog_add_without_package(self):
37+
parser = _build_parser()
38+
args = parser.parse_args(["changelog", "add"])
39+
assert args.changelog_command == "add"
40+
assert args.package is None
41+
42+
def test_changelog_add_with_package(self):
43+
parser = _build_parser()
44+
args = parser.parse_args(["changelog", "add", "sdk/storage/azure-storage-blob"])
45+
assert args.changelog_command == "add"
46+
assert args.package == "sdk/storage/azure-storage-blob"
47+
48+
def test_changelog_create(self):
49+
parser = _build_parser()
50+
args = parser.parse_args(["changelog", "create"])
51+
assert args.changelog_command == "create"
52+
53+
def test_changelog_status(self):
54+
parser = _build_parser()
55+
args = parser.parse_args(["changelog", "status"])
56+
assert args.changelog_command == "status"
57+
58+
def test_changelog_no_subcommand_prints_help(self):
59+
"""When no subcommand is given, the handler should print help and return 1."""
60+
parser = _build_parser()
61+
args = parser.parse_args(["changelog"])
62+
assert hasattr(args, "func")
63+
# func should be the _no_subcommand method
64+
result = args.func(args)
65+
assert result == 1
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# Execution tests (npx / chronus invocation)
70+
# ---------------------------------------------------------------------------
71+
72+
73+
class TestChangelogExecution:
74+
"""Verify that each subcommand invokes chronus with the right arguments."""
75+
76+
@patch("azpysdk.changelog.subprocess.call", return_value=0)
77+
@patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx")
78+
def test_add_calls_chronus_add(self, mock_which, mock_call):
79+
parser = _build_parser()
80+
args = parser.parse_args(["changelog", "add"])
81+
result = args.func(args)
82+
assert result == 0
83+
mock_call.assert_called_once()
84+
cmd = mock_call.call_args[0][0]
85+
assert cmd == ["/usr/bin/npx", "chronus", "add"]
86+
87+
@patch("azpysdk.changelog.subprocess.call", return_value=0)
88+
@patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx")
89+
def test_add_with_package_passes_package(self, mock_which, mock_call):
90+
parser = _build_parser()
91+
args = parser.parse_args(["changelog", "add", "sdk/core/azure-core"])
92+
result = args.func(args)
93+
assert result == 0
94+
cmd = mock_call.call_args[0][0]
95+
assert cmd == ["/usr/bin/npx", "chronus", "add", "sdk/core/azure-core"]
96+
97+
@patch("azpysdk.changelog.subprocess.call", return_value=0)
98+
@patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx")
99+
def test_verify_calls_chronus_verify(self, mock_which, mock_call):
100+
parser = _build_parser()
101+
args = parser.parse_args(["changelog", "verify"])
102+
result = args.func(args)
103+
assert result == 0
104+
cmd = mock_call.call_args[0][0]
105+
assert cmd == ["/usr/bin/npx", "chronus", "verify"]
106+
107+
@patch("azpysdk.changelog.subprocess.call", return_value=0)
108+
@patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx")
109+
def test_create_calls_chronus_changelog(self, mock_which, mock_call):
110+
parser = _build_parser()
111+
args = parser.parse_args(["changelog", "create"])
112+
result = args.func(args)
113+
assert result == 0
114+
cmd = mock_call.call_args[0][0]
115+
assert cmd == ["/usr/bin/npx", "chronus", "changelog"]
116+
117+
@patch("azpysdk.changelog.subprocess.call", return_value=0)
118+
@patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx")
119+
def test_status_calls_chronus_status(self, mock_which, mock_call):
120+
parser = _build_parser()
121+
args = parser.parse_args(["changelog", "status"])
122+
result = args.func(args)
123+
assert result == 0
124+
cmd = mock_call.call_args[0][0]
125+
assert cmd == ["/usr/bin/npx", "chronus", "status"]
126+
127+
@patch("azpysdk.changelog.subprocess.call", return_value=0)
128+
@patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx")
129+
def test_chronus_runs_from_repo_root(self, mock_which, mock_call):
130+
"""Chronus must run from the repository root directory."""
131+
parser = _build_parser()
132+
args = parser.parse_args(["changelog", "verify"])
133+
args.func(args)
134+
_, kwargs = mock_call.call_args
135+
from azpysdk.changelog import REPO_ROOT
136+
assert kwargs["cwd"] == REPO_ROOT
137+
138+
@patch("azpysdk.changelog.subprocess.call", return_value=1)
139+
@patch("azpysdk.changelog.shutil.which", return_value="/usr/bin/npx")
140+
def test_nonzero_exit_code_propagated(self, mock_which, mock_call):
141+
parser = _build_parser()
142+
args = parser.parse_args(["changelog", "verify"])
143+
result = args.func(args)
144+
assert result == 1
145+
146+
147+
# ---------------------------------------------------------------------------
148+
# Error handling tests
149+
# ---------------------------------------------------------------------------
150+
151+
152+
class TestChangelogErrors:
153+
"""Verify error handling when prerequisites are missing."""
154+
155+
@patch("azpysdk.changelog.shutil.which", return_value=None)
156+
def test_npx_not_found_raises(self, mock_which):
157+
parser = _build_parser()
158+
args = parser.parse_args(["changelog", "verify"])
159+
with pytest.raises(FileNotFoundError, match="npx not found"):
160+
args.func(args)

0 commit comments

Comments
 (0)