Skip to content

Commit c5c0d62

Browse files
authored
Merge branch 'main' into remove-ruff
2 parents ced878c + 3475355 commit c5c0d62

9 files changed

Lines changed: 3250 additions & 28 deletions

File tree

.github/chronus/package-lock.json

Lines changed: 2509 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/chronus/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "azure-sdk-for-python-changelog-tools",
3+
"private": true,
4+
"description": "Pinned Node dev dependencies used by 'azpysdk changelog' (Chronus).",
5+
"devDependencies": {
6+
"@chronus/chronus": "1.3.1",
7+
"@chronus/github": "1.0.6"
8+
}
9+
}

.github/workflows/azure-sdk-tools.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ jobs:
7979

8080
- name: Install azure-sdk-tools on in global uv, discover azpysdk checks
8181
run: |
82-
uv pip install --system eng/tools/azure-sdk-tools[ghtools,conda,systemperf]
82+
uv pip install --system -e eng/tools/azure-sdk-tools[ghtools,conda,systemperf]
8383
8484
# Discover available azpysdk commands from the {command1,command2,...} line in help output
8585
CHECKS=$(azpysdk -h 2>&1 | \
@@ -88,6 +88,7 @@ jobs:
8888
tr -d '{}' | \
8989
tr ',' '\n' | \
9090
grep -v '^next-' | \
91+
grep -v '^changelog$' | \
9192
sort | \
9293
paste -sd,)
9394
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import argparse
2+
import os
3+
import shutil
4+
import subprocess
5+
import sys
6+
from typing import NoReturn, Optional, List
7+
8+
from .Check import Check, REPO_ROOT
9+
from ci_tools.functions import get_package_from_repo
10+
from ci_tools.logging import logger
11+
12+
# Chronus is pinned as a dev dependency in .github/chronus/package.json with
13+
# a committed lockfile so both the top-level version and all transitive
14+
# dependencies are reproducible.
15+
_CHRONUS_INSTALL_DIR = os.path.join(".github", "chronus")
16+
_CHRONUS_BIN_NAME = "chronus.cmd" if os.name == "nt" else "chronus"
17+
_CHRONUS_BIN_PATH = os.path.join(_CHRONUS_INSTALL_DIR, "node_modules", ".bin", _CHRONUS_BIN_NAME)
18+
19+
_FALLBACK_CHANGE_KINDS = ["breaking changes", "features added", "deprecation", "fix", "dependencies", "internal"]
20+
21+
22+
def _load_change_kinds() -> List[str]:
23+
"""Read valid change kinds from ``.chronus/config.yaml``.
24+
25+
Falls back to a hardcoded list if the config file is missing or
26+
cannot be parsed (e.g. pyyaml not installed).
27+
"""
28+
config_path = os.path.join(REPO_ROOT, ".chronus", "config.yaml")
29+
try:
30+
import yaml
31+
32+
with open(config_path) as f:
33+
config = yaml.safe_load(f)
34+
kinds = list(config.get("changeKinds", {}).keys())
35+
if kinds:
36+
return kinds
37+
except Exception:
38+
pass
39+
return list(_FALLBACK_CHANGE_KINDS)
40+
41+
42+
_CHANGE_KINDS = _load_change_kinds()
43+
44+
45+
def _add_package_argument(parser: argparse.ArgumentParser, action: str) -> None:
46+
"""Add the common ``package`` positional argument to a subparser."""
47+
parser.add_argument(
48+
"package",
49+
nargs="?",
50+
default=None,
51+
help=(f"Package path (e.g. sdk/storage/azure-storage-blob) to {action}. "),
52+
)
53+
54+
55+
class changelog(Check):
56+
"""Manage changelogs with Chronus.
57+
58+
Wraps Chronus CLI commands (add, verify, create, status) so they can be
59+
invoked through the ``azpysdk`` CLI. Commands can be run from the
60+
repository root **or** from within a package directory — the tool will
61+
detect the package path automatically when possible.
62+
"""
63+
64+
def __init__(self) -> None:
65+
super().__init__()
66+
self._parser: Optional[argparse.ArgumentParser] = None
67+
68+
def register(
69+
self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None
70+
) -> None:
71+
# parent_parsers intentionally unused — changelog commands operate at
72+
# the repository level via Chronus, not on individual packages.
73+
p = subparsers.add_parser(
74+
"changelog",
75+
help="Manage changelogs with Chronus (add, verify, create, status)",
76+
)
77+
self._parser = p
78+
sub = p.add_subparsers(title="changelog commands", dest="changelog_command")
79+
80+
# changelog add
81+
add_p = sub.add_parser("add", help="Add a chronus change entry for modified packages")
82+
_add_package_argument(add_p, "add an entry for")
83+
add_p.add_argument(
84+
"--kind",
85+
"-k",
86+
choices=_CHANGE_KINDS,
87+
default=None,
88+
help="Kind of change (e.g. breaking changes, features added, fix). If omitted, chronus will prompt interactively.",
89+
)
90+
add_p.add_argument(
91+
"--message",
92+
"-m",
93+
default=None,
94+
help="Short description of the change. If omitted, chronus will prompt interactively.",
95+
)
96+
add_p.set_defaults(func=self._run_add)
97+
98+
# changelog verify
99+
verify_p = sub.add_parser("verify", help="Verify all modified packages have change entries")
100+
verify_p.set_defaults(func=self._run_verify)
101+
102+
# changelog create
103+
create_p = sub.add_parser("create", help="Generate CHANGELOG.md from pending chronus entries")
104+
_add_package_argument(create_p, "generate changelog for")
105+
create_p.set_defaults(func=self._run_create)
106+
107+
# changelog status
108+
status_p = sub.add_parser("status", help="Show a summary of pending changes and resulting version bumps")
109+
_add_package_argument(status_p, "show status for")
110+
status_p.set_defaults(func=self._run_status)
111+
112+
p.set_defaults(func=self._no_subcommand)
113+
114+
# Internal helpers
115+
116+
def _no_subcommand(self, args: argparse.Namespace) -> int:
117+
"""Print help when no changelog subcommand is provided."""
118+
if self._parser:
119+
self._parser.print_help()
120+
return 1
121+
122+
@staticmethod
123+
def _is_chronus_installed() -> bool:
124+
"""Return ``True`` if Chronus is installed locally in *node_modules*."""
125+
return os.path.isfile(os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH))
126+
127+
def _ensure_chronus_installed(self) -> None:
128+
"""Verify Chronus is installed locally, offering to install if not.
129+
130+
Runs ``npm ci`` against ``.github/chronus`` so only the exact
131+
versions recorded in ``package-lock.json`` are installed.
132+
Raises ``SystemExit`` if the user declines or installation fails.
133+
"""
134+
if self._is_chronus_installed():
135+
return
136+
137+
install_dir = os.path.join(REPO_ROOT, _CHRONUS_INSTALL_DIR)
138+
139+
npm = shutil.which("npm")
140+
if not npm:
141+
self._fail_no_npm(install_dir)
142+
143+
self._prompt_or_autoinstall(install_dir)
144+
self._npm_ci(npm, install_dir)
145+
146+
@staticmethod
147+
def _fail_no_npm(install_dir: str) -> NoReturn:
148+
logger.error(
149+
"Chronus is not installed and npm was not found on PATH.\n"
150+
"Please install Node.js (LTS) from https://nodejs.org/ then run:\n\n"
151+
f" cd {install_dir}\n"
152+
" npm ci\n"
153+
)
154+
raise SystemExit(1)
155+
156+
@staticmethod
157+
def _prompt_or_autoinstall(install_dir: str) -> None:
158+
"""Prompt for installation interactively, or check env var in CI."""
159+
if sys.stdin.isatty():
160+
print(
161+
f"\nChronus is not installed locally. It is pinned as a dev dependency\n"
162+
f"in {os.path.join(install_dir, 'package.json')}.\n"
163+
)
164+
answer = input(f"Run 'npm ci' in {_CHRONUS_INSTALL_DIR} to install it? [Y/n] ").strip().lower()
165+
if answer not in ("", "y", "yes"):
166+
logger.info("Skipped Chronus installation.")
167+
raise SystemExit(1)
168+
elif not os.environ.get("AZPYSDK_AUTO_INSTALL"):
169+
logger.error(
170+
"Chronus is not installed and running in non-interactive mode.\n"
171+
"Set AZPYSDK_AUTO_INSTALL=1 to allow automatic installation, or run:\n\n"
172+
f" cd {install_dir}\n"
173+
" npm ci\n"
174+
)
175+
raise SystemExit(1)
176+
else:
177+
logger.info("AZPYSDK_AUTO_INSTALL set — running 'npm ci' automatically.")
178+
179+
def _npm_ci(self, npm: str, install_dir: str) -> None:
180+
"""Run ``npm ci`` and verify Chronus was installed."""
181+
logger.info(f"Running: npm ci (cwd: {install_dir})")
182+
rc = subprocess.call([npm, "ci"], cwd=install_dir)
183+
if rc != 0:
184+
logger.error("'npm ci' failed. Please resolve npm errors and try again.")
185+
raise SystemExit(rc)
186+
187+
if not self._is_chronus_installed():
188+
logger.error(
189+
"'npm ci' succeeded but Chronus was not found in node_modules.\n"
190+
f"Expected: {os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH)}\n"
191+
f"Please verify that {os.path.join(_CHRONUS_INSTALL_DIR, 'package.json')} "
192+
"lists @chronus/chronus as a dependency."
193+
)
194+
raise SystemExit(1)
195+
196+
@staticmethod
197+
def _resolve_package(package_arg: Optional[str]) -> Optional[str]:
198+
"""Resolve a package argument to a Chronus package name."""
199+
if not package_arg:
200+
return None
201+
# Resolve relative paths (e.g. ".") to absolute so get_package_from_repo
202+
# doesn't accidentally glob against the repo root.
203+
target = os.path.abspath(package_arg) if os.path.exists(package_arg) else package_arg
204+
try:
205+
parsed = get_package_from_repo(target, REPO_ROOT)
206+
return parsed.name if parsed else package_arg
207+
except RuntimeError:
208+
return package_arg # passthrough for unresolvable names
209+
210+
def _run_chronus(self, chronus_args: List[str]) -> int:
211+
"""Run a chronus command from the repository root."""
212+
self._ensure_chronus_installed()
213+
cmd = [os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH), *chronus_args]
214+
logger.info(f"Running: {' '.join(cmd)}")
215+
return subprocess.call(cmd, cwd=REPO_ROOT)
216+
217+
# Subcommand handlers
218+
219+
def _run_add(self, args: argparse.Namespace) -> int:
220+
"""Run ``chronus add`` to interactively add a change entry."""
221+
chronus_args = ["add"]
222+
package = self._resolve_package(args.package)
223+
if package:
224+
chronus_args.append(package)
225+
if args.kind:
226+
chronus_args.extend(["--kind", args.kind])
227+
if args.message:
228+
chronus_args.extend(["--message", args.message])
229+
return self._run_chronus(chronus_args)
230+
231+
def _run_verify(self, args: argparse.Namespace) -> int:
232+
"""Run ``chronus verify`` to check for missing change entries."""
233+
return self._run_chronus(["verify"])
234+
235+
def _run_create(self, args: argparse.Namespace) -> int:
236+
"""Run ``chronus changelog`` to generate CHANGELOG.md files."""
237+
package = self._resolve_package(args.package)
238+
if not package:
239+
logger.error(
240+
"No package specified and could not detect one from the current directory.\n"
241+
"Either run from within a package directory (e.g. sdk/core/azure-core) or\n"
242+
"pass the package path explicitly:\n\n"
243+
" azpysdk changelog create sdk/core/azure-core\n"
244+
)
245+
return 1
246+
247+
rc = self._run_chronus(["changelog", "--package", package])
248+
if rc != 0:
249+
logger.info(
250+
"Hint: if Chronus reported 'No release action found', it means there are no\n"
251+
"pending change entries for this package. Run 'azpysdk changelog add' first to\n"
252+
"create a change entry, then re-run 'azpysdk changelog create'."
253+
)
254+
return rc
255+
256+
def _run_status(self, args: argparse.Namespace) -> int:
257+
"""Run ``chronus status`` to show pending changes."""
258+
chronus_args = ["status"]
259+
package = self._resolve_package(args.package)
260+
if package:
261+
chronus_args.extend(["--only", package])
262+
return self._run_chronus(chronus_args)

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
from .devtest import devtest
4141
from .optional import optional
4242
from .update_snippet import update_snippet
43+
from .changelog import changelog
44+
4345
from ci_tools.logging import configure_logging, logger
4446

4547
__all__ = ["main", "build_parser"]
@@ -152,6 +154,7 @@ def build_parser() -> argparse.ArgumentParser:
152154
devtest().register(subparsers, [common])
153155
optional().register(subparsers, [common])
154156
update_snippet().register(subparsers, [common])
157+
changelog().register(subparsers, [common])
155158

156159
return parser
157160

0 commit comments

Comments
 (0)