diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01451d9..e751df8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: # One entry per tool. Add a directory here when you add a package. - package: [ai-failure-notifier] + package: [ai-failure-notifier, changelog] python-version: ['3.10', '3.12', '3.14'] defaults: run: diff --git a/README.md b/README.md index 91ba797..429e50e 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Each tool is its own package in its own top-level directory, with its own `pypro | directory | what it does | |---|---| | [`ai-failure-notifier`](ai-failure-notifier) | Triages and enriches the issue opened when a scheduled workflow fails. | +| [`changelog`](changelog) | Turns a range of commits into our changelog format. | Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA: diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 0000000..72227ce --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,123 @@ +# changelog + +Turns a range of commits into our changelog format. + +The Charm Tech repositories have different release processes, but aim for a consistent changelog style. The formatting and the version arithmetic are centralised here, the file rewriting and the GitHub calls are in each repository. + +## Using it + +```python +import datetime +import subprocess +from charm_tech_code.changelog import ( + GIT_LOG_FORMAT, + format_changes, + format_release_notes, + infer_bump_size, + next_version, + parse_git_log, +) + +log = subprocess.run( + ['git', 'log', '--reverse', '--no-merges', f'--format={GIT_LOG_FORMAT}', '3.8.1..3.8.2'], + capture_output=True, + text=True, + check=True, +).stdout +categories = parse_git_log(log, team=MAINTAINERS, repo='canonical/operator') +notes = format_release_notes(categories, None, repo='canonical/operator') +version = next_version(previous='3.8.1', size=infer_bump_size(categories)) +entry = format_changes(categories, version, datetime.date.today()) +``` + +Running `git log` is the caller's job, as is getting hold of the date: nothing in the library touches the network, git, the filesystem or the clock. That is what lets the tests pin the real behaviour rather than approximate it. + +### The pull-request number, and the link + +A change carries the *number*, taken from the `(#N)` a squash merge appends to the subject, and never a URL. `format_changes` only ever wanted the number, and `format_release_notes` builds the link back up from the number and the `repo` you give it - a string operation, so the no-I/O rule holds. + +A commit with no `(#N)` - one pushed straight to the branch - carries `None`, and renders with no reference at all rather than with a `(#?)` standing in for one. It is a real change; what it has not got is a pull request to point anyone at. + +### Credit + +A contributor from outside the team maintaining the repository is named in the bullet: `* Fix typos in code snippets by @MattiaSarti (#1750)`, which is what operator's own `CHANGES.md` has always done by hand. A member of that team is not - a maintainer is not a guest, and a changelog whose every line ends in the same three handles has stopped carrying information. + +Pass the team as `team=`, a collection of email addresses and/or GitHub handles. It is a parameter rather than a constant because it drifts, and it differs per repository. **An empty team credits everyone**, which is the right way for this to fail: over-crediting is visible in the draft release and takes one edit, while crediting nobody is invisible until a contributor notices. + +Two things about who is outside: + +* **"Outside the team" is not "outside Canonical".** Someone from another Canonical team has an `@canonical.com` address and every bit as much claim to the credit. +* **A handle is only sometimes recoverable.** `46688206+Ali-932@users.noreply.github.com` gives `@Ali-932`, which is GitHub's default for an account with a private email and so the usual case for a drive-by contributor. Where there is no handle in the log, the person is credited by name, because dropping them and rendering a broken `@` are both worse. + +### Reverts + +Handled explicitly, on the git-log path: + +* **A revert of something in the same range cancels with it**, and neither appears. A change that landed and was taken back out before anything shipped did not happen as far as a reader is concerned. +* **A revert of something already released is called out**, under its own `Reverted` heading rather than filed under the type it undoes - the reader wants to see that something was withdrawn, not a fix that looks new. It counts at least as a patch, and a revert of a released *feature* is routed to `Breaking Changes`, because taking away behaviour people may be relying on is a breaking change whatever the revert commit's type says. + +The key is the pull-request number in `Reverts owner/repo#N`, not a SHA. Under squash merging the reverted commit's SHA on the default branch bears no relation to anything a contributor would cite. + +## From a workflow step + +The console script is the same thing for a caller that can't `import`. It reads the range on stdin and prints one answer: + +```shell +git log --reverse --no-merges --format="$(changelog git-log-format)" "$LAST_TAG..$BRANCH" > log.txt +SIZE=$(changelog bump-size --team "$TEAM" < log.txt) +VERSION=$(changelog next-version --previous "$LAST_TAG" --team "$TEAM" < log.txt) +changelog release-notes --repo "$REPO" --team "$TEAM" \ + --compare-url "https://github.com/$REPO/compare/$LAST_TAG...$VERSION" < log.txt > release-notes.md +changelog changes-entry --tag "$VERSION" --team "$TEAM" < log.txt > changes-entry.md +``` + +That shape comes from how an Actions step consumes a result. A `$GITHUB_OUTPUT` line takes a scalar comfortably and a multi-line document only through a heredoc delimiter the document itself must not contain, so the two commands that produce Markdown print it on stdout for the step to redirect into a file, and the two that produce a scalar print a single bare word, with no label and no JSON to unwrap. Nothing here writes to `$GITHUB_OUTPUT` itself, which keeps the script useful outside Actions. + +Four invocations re-parse the same text four times. That costs nothing worth counting, and it is the reason each step's output needs no reshaping. + +`git-log-format` is the fifth and the odd one out: it reads nothing, and prints the `--format` string the others expect. Copying that string into the workflow instead would work until someone dropped a separator out of it, and a log that does not parse yields an empty changelog rather than an error. + +`--compare-url` is there because a git log does not carry a compare link and the tags at either end of the range are the workflow's to know. You pass the link; the `**Full Changelog**:` prefix is the package's, so that notes rendered here read the same as notes rendered by GitHub. Leave it off for no closing line. + +`--date` defaults to today (UTC), and `_cli` is the only module in the package that reads the clock. The library stays clock-free, and a fixture in the test suite fails the whole run if that stops being true. + +Run it from a workflow pinned to a commit: + +```shell +uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=changelog" changelog bump-size < log.txt +``` + +## Versions + +`infer_bump_size` answers "how big a release is this range", and `next_version` applies that answer to a plain `X.Y.Z`. The rule is that a `feat` in the range means minor and anything else means patch, with two things worth saying out loud: + +* **A breaking change counts as a feature.** A `!` moves an entry out of its real type and into `breaking`, so a range whose only feature is a `feat!` has an empty `feat` list, and a rule that read `feat` alone would call it a patch. A `!` doesn't infer a *major* bump, for the reason in "The format" below, but a breaking change riding in a patch release isn't the bend of the rules anyone agreed to. operator's 3.8.0 shipped a `refactor!` in a minor release, which is the case this matches. +* **A major bump is never inferred.** Nor is a pre-release, and `next_version` raises rather than guess at anything that isn't a plain `X.Y.Z`: `3.9.0.dev0` is the one to watch, since that's what sits in the version file between releases and it's a guess made by the last post-release bump rather than a version anyone shipped. A release that isn't an ordinary next one is a deliberate act, and the workflow's explicit version input is how to say so. + +Where the line falls: the package says what size the range is and does the semver arithmetic, and the repository decides what to count from, whether a `.dev0` goes on the end afterwards, and what any of it implies for the other packages it ships. A `minor` on a branch where a feature has no business appearing, such as operator's `2.23-maintenance`, is an error rather than a patch, but it's the repository that knows which branches those are, so that check belongs there too. + +## The format + +`format_release_notes` produces the body of a GitHub release, and `format_changes` produces one `CHANGES.md` entry: + +```markdown +# 3.8.2 - 31 August 2026 + +## Fixes + +* Compare full event paths when skipping duplicate notices (#2684) +``` + +Neither shape is injectable, and neither is the map of commit type to heading. The format is common across our repositories and the set of types is enforced by a shared PR-title check, so there is no second format for an adopting repository to supply, and a template system here would exist for a caller that doesn't. + +Two things about that map are worth knowing: + +* `chore` is a type but not a category, so `chore` commits are deliberately dropped. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and these sorts of changes are not interesting to our users, and they are available via `git log` if anyone does want them. +* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A revert of a released `feat` lands there too, for the reason in "Reverts" above. A `!` should be a major version bump, but if it's appearing here then we have decided to cheat the semver rules and allow a breaking change in a minor release. This should be rare. We will have carefully checked the impact before this decision, but want to make sure the change is particularly noticeable in the changelog. + +## Developing + +```shell +uv sync --group unit +uv run pytest +``` diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml new file mode 100644 index 0000000..10ff25e --- /dev/null +++ b/changelog/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "charm-tech-code-changelog" +version = "0.1.0" +description = "Turn a range of commits into our changelog format." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + {name = "The Charm Tech team at Canonical Ltd."}, +] +license = "Apache-2.0" +# No runtime dependencies, and that is worth keeping. The package is pure +# text-to-text: it does not talk to GitHub, run git, or read the clock, so +# there is nothing for a dependency to do. +dependencies = [] + +# The importable API is the package; this is the same thing wrapped for a +# workflow step, which cannot `import`. `argparse` is stdlib, so it stays a +# dependency-free package. +[project.scripts] +changelog = "charm_tech_code.changelog._cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/charm_tech_code"] + +[dependency-groups] +unit = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# The real ruff configuration is at the root of the monorepo; extending it +# means a setting added here overrides one key rather than the whole config. +[tool.ruff] +extend = "../pyproject.toml" diff --git a/changelog/src/charm_tech_code/changelog/__init__.py b/changelog/src/charm_tech_code/changelog/__init__.py new file mode 100644 index 0000000..e498d2b --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/__init__.py @@ -0,0 +1,70 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Turn a range of commits into our changelog format. + +Text in, structured data and formatted strings out. Nothing here touches the +network, git, the filesystem or the clock, so a caller supplies the log text +and the date and decides what to do with what comes back:: + + log = subprocess.run( + ['git', 'log', '--reverse', '--no-merges', f'--format={GIT_LOG_FORMAT}', '3.8.1..3.8.2'], + capture_output=True, text=True, check=True, + ).stdout + categories = parse_git_log(log, team=MAINTAINERS, repo='canonical/operator') + notes = format_release_notes(categories, None, repo='canonical/operator') + entry = format_changes(categories, '3.8.2', datetime.date.today()) + +The same parse answers how big a release the range adds up to, and what that +makes the version after `previous`:: + + size = infer_bump_size(categories) + version = next_version(previous=previous, size=size) + +The format is the package's own, not a parameter -- see `_constants` for +what that means and why `chore` commits do not appear in a changelog. The +`changelog` console script (`_cli`) wraps all of the above for a workflow +step, and is the one place in the package that does any I/O. +""" + +from __future__ import annotations + +from ._constants import ( + CATEGORIES, + CATEGORY_HEADINGS, + GIT_LOG_FORMAT, + MINOR_BUMP_CATEGORIES, +) +from ._format import commit_type_to_category, format_changes, format_release_notes +from ._models import Change +from ._parse import parse_git_log +from ._version import MINOR, PATCH, BumpSize, infer_bump_size, next_version + +__all__ = [ + 'CATEGORIES', + 'CATEGORY_HEADINGS', + 'GIT_LOG_FORMAT', + 'MINOR', + 'MINOR_BUMP_CATEGORIES', + 'PATCH', + 'BumpSize', + 'Change', + 'commit_type_to_category', + 'format_changes', + 'format_release_notes', + 'infer_bump_size', + 'next_version', + 'parse_git_log', +] diff --git a/changelog/src/charm_tech_code/changelog/_authors.py b/changelog/src/charm_tech_code/changelog/_authors.py new file mode 100644 index 0000000..aaccc5a --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_authors.py @@ -0,0 +1,95 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Who gets credited in a changelog, and how. + +The rule is that a contributor from outside the team that maintains the +repository is credited by name in the entry, and a member of that team is +not. + +**"Outside the team" is not "outside Canonical".** A contributor from +another Canonical team has an `@canonical.com` address, no GitHub handle +anyone can derive from it, and every bit as much claim to the credit as a +stranger does. They are credited by name. + +**The team is a parameter, not a constant.** It drifts -- people join and +leave -- and it differs per repository, so a list baked in here would be +wrong somewhere from the day it was written. An empty team credits everyone, +which is the right way for this to fail: over-crediting is visible in a +draft release and takes one edit to fix, while quietly crediting nobody is +invisible until a contributor notices they were left out. + +The git log gives a name and an email, never a handle, and only some of that +is recoverable: see `NOREPLY_EMAIL_REGEX`. Where it is not, the person is +credited by their name, because the alternatives are to drop them or to +render a handle that does not exist. +""" + +from __future__ import annotations + +from collections.abc import Collection + +from ._constants import NOREPLY_EMAIL_REGEX + + +def normalise_team(team: Collection[str]) -> frozenset[str]: + """Fold a caller's team list into something to compare against. + + Entries may be email addresses or GitHub handles, with or without a + leading `@`, in any mixture: a caller assembling the list from a team + page has both to hand and should not have to decide which kind each one + is. Comparison is case-insensitive, since neither an email address nor a + GitHub handle distinguishes case. + """ + return frozenset(member.strip().lstrip('@').casefold() for member in team if member.strip()) + + +def derive_handle(email: str) -> str | None: + """Recover a GitHub handle from an author email, if it is in there. + + Only a `users.noreply.github.com` address carries one. That is not the + narrow case it sounds like: it is what GitHub commits as by default when + an account keeps its email private, so it is the usual form for a + drive-by contributor, which is the author this most needs to name. + + Returns: + The handle without its `@`, or `None` for any other address. + + """ + match = NOREPLY_EMAIL_REGEX.match(email.strip()) + return match.group('handle') if match else None + + +def credit_for(name: str, email: str, team: Collection[str]) -> str | None: + """Work out how to credit the author of a commit, from a git log. + + Args: + name: The author name, as `%an` gives it. + email: The author email, as `%ae` gives it. + team: The maintainers, as emails and/or handles. See `normalise_team`. + + Returns: + `@handle` when a handle can be recovered from the email, the name + when it cannot, or `None` when this author is one of `team` and so + is not a guest to be thanked. + + """ + members = normalise_team(team) + handle = derive_handle(email) + if email.strip().casefold() in members: + return None + if handle is not None and handle.casefold() in members: + return None + return f'@{handle}' if handle is not None else name.strip() or None diff --git a/changelog/src/charm_tech_code/changelog/_cli.py b/changelog/src/charm_tech_code/changelog/_cli.py new file mode 100644 index 0000000..7215cb6 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_cli.py @@ -0,0 +1,273 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +r"""The console script: a range of changes on stdin, one answer on stdout. + +**This module is the package's I/O boundary, and the only one.** Everything +under it is text in, text out -- no network, no git, no filesystem, no clock +-- and the test suite's `no_clock` fixture holds that line by making a +`datetime` call from the library modules fail. This file is deliberately +outside that fixture, because `--date` has to default to something and +"today" is the only sensible default for a workflow that runs on the day it +releases. If you find yourself wanting the clock, or a file, or an API call, +in any other module of this package: it goes here instead. + +Note what is *not* here: running `git log`. The log text arrives on stdin +exactly as the notes text does, so the boundary this module draws is around +the clock and the standard streams, and the caller keeps the subprocess. + +The shape is driven by how a GitHub Actions step consumes a result, which is +either as a `$GITHUB_OUTPUT` line or as a file. A `$GITHUB_OUTPUT` line takes +a scalar comfortably and a multi-line document only through a heredoc +delimiter that the document itself must not contain -- so the two commands +that emit Markdown emit it on stdout, for the step to redirect into a file, +and the two that emit a scalar emit a single bare word with no decoration, so +that `size=$(changelog bump-size < log.txt)` is the whole of the plumbing:: + + git log --reverse --no-merges --format="$(changelog git-log-format)" \\ + "$LAST_TAG..$BRANCH" > log.txt + SIZE=$(changelog bump-size --team "$TEAM" < log.txt) + VERSION=$(changelog next-version --previous "$LAST_TAG" --team "$TEAM" < log.txt) + changelog release-notes --repo "$REPO" --team "$TEAM" < log.txt > release-notes.md + changelog changes-entry --tag "$VERSION" --team "$TEAM" < log.txt > changes-entry.md + +Five invocations re-parse the same text four times, which costs nothing and +buys each step an output that goes where it belongs without any reshaping. +`git-log-format` is the odd one out: it reads nothing and prints the +`--format` string the other four expect, so that the exact sequence of +`%x1e` and `%x1f` lives in one place rather than being copied into every +workflow that calls this. +""" + +from __future__ import annotations + +import argparse +import datetime +import sys +from collections.abc import Sequence + +from ._constants import GIT_LOG_FORMAT +from ._format import format_changes, format_release_notes +from ._models import Change +from ._parse import parse_git_log +from ._version import infer_bump_size, next_version + + +def _today() -> datetime.date: + """Return the default for `--date`, the package's only reading of the clock. + + UTC rather than local time: the runner is UTC, and a release's date + should not depend on who ran it from where. + """ + return datetime.datetime.now(datetime.timezone.utc).date() + + +def _emit(text: str) -> None: + """Write one answer to stdout, newline-terminated. + + The text is otherwise passed through exactly as the library produced it. + `format_changes` output is prepended to a `CHANGES.md` verbatim, so the + blank lines at its end are part of the answer rather than padding to be + tidied up here. + """ + sys.stdout.write(text if text.endswith('\n') else text + '\n') + + +def _input_options() -> argparse.ArgumentParser: + """Build the parent parser for the options that say what arrives on stdin. + + Shared by the four subcommands that read it, as a parent parser, so that + a caller switching input path changes one flag on every command rather + than learning four spellings of it. + """ + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument( + '--team', + action='append', + default=None, + metavar='EMAIL-OR-HANDLE,...', + help=( + 'Authors not to credit, comma-separated, as email addresses ' + 'and/or GitHub handles. Repeatable. These are the people who ' + 'maintain the repository; everyone else is credited by handle, ' + 'or by name where no handle can be worked out. The default ' + 'credits everyone.' + ), + ) + return parser + + +def _repo_option(parser: argparse.ArgumentParser, *, required: bool) -> None: + parser.add_argument( + '--repo', + required=required, + metavar='OWNER/NAME', + help=( + 'The repository the pull-request links point into. A change ' + 'carries a number rather than a URL -- a number is all a git log ' + 'has -- so the links are built from this.' + ), + ) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog='changelog', + description=( + 'Turn a range of changes, read from stdin, into our changelog ' + 'format or into a version decision.' + ), + ) + subparsers = parser.add_subparsers(dest='command', required=True) + shared = [_input_options()] + + subparsers.add_parser( + 'bump-size', + parents=shared, + help="Print 'minor' or 'patch' for the changes on stdin.", + description=( + "Print 'minor' if the range contains a feature or a breaking change, " + "and 'patch' otherwise. Never 'major': that is a deliberate act, not " + 'something to infer. On a branch where a feature should not appear at ' + 'all, such as a maintenance branch, treat a "minor" here as an error ' + 'rather than releasing from it.' + ), + ) + + next_version_parser = subparsers.add_parser( + 'next-version', + parents=shared, + help='Print the version that follows --previous, given the changes on stdin.', + description=( + 'Apply the inferred bump size to --previous and print the result. ' + 'Only a plain X.Y.Z is accepted; whether the answer then gains a ' + 'pre-release or dev suffix, and what it implies for any other ' + 'package version in the repository, is for the caller to decide.' + ), + ) + next_version_parser.add_argument( + '--previous', + required=True, + metavar='X.Y.Z', + help='The version this release follows, normally the last tag on the branch.', + ) + + release_notes_parser = subparsers.add_parser( + 'release-notes', + parents=shared, + help='Print the release body, as Markdown.', + description=( + 'Print the body of a GitHub release: the changes by category, ' + 'breaking ones first, and a compare link at the end if there is ' + 'one to print.' + ), + ) + _repo_option(release_notes_parser, required=True) + release_notes_parser.add_argument( + '--compare-url', + default=None, + metavar='URL', + help=( + 'The compare link to end on. A git log carries no such link, and ' + "the tags at either end of the range are the caller's to know, so " + 'this is how to have one. Omit it for no link at all.' + ), + ) + + changes_entry_parser = subparsers.add_parser( + 'changes-entry', + parents=shared, + help='Print one CHANGES.md entry, as Markdown.', + description=( + 'Print a single CHANGES.md entry for the release, to be prepended ' + 'to the existing file.' + ), + ) + _repo_option(changes_entry_parser, required=False) + changes_entry_parser.add_argument( + '--tag', + required=True, + help='The version being released, used verbatim in the entry heading.', + ) + changes_entry_parser.add_argument( + '--date', + type=datetime.date.fromisoformat, + default=None, + metavar='YYYY-MM-DD', + help="The release date. Defaults to today's date, in UTC.", + ) + + subparsers.add_parser( + 'git-log-format', + help='Print the git log --format string the other commands expect.', + description=( + 'Print the --format string to pass to `git log`, and nothing else: ' + '`git log --format="$(changelog git-log-format)"`. Copying the ' + 'string into a workflow instead would work until someone dropped a ' + 'separator out of it, at which point the parse yields an empty ' + 'changelog rather than an error, and a release goes out with ' + 'nothing in its notes.' + ), + ) + + return parser + + +def _team(args: argparse.Namespace) -> list[str]: + """`--team a,b --team c` as one flat list. + + Both spellings, because a workflow passing a repository variable has one + string with commas in it and a human typing the command has neither. + """ + members: list[str] = [] + for group in args.team or (): + members.extend(group.split(',')) + return members + + +def _categories(args: argparse.Namespace, text: str) -> dict[str, list[Change]]: + """Parse the git log on stdin into categories.""" + repo = getattr(args, 'repo', None) + return parse_git_log(text, team=_team(args), repo=repo) + + +def main(argv: Sequence[str] | None = None) -> int: + """Parse the changes on stdin and print the answer the subcommand asks for.""" + args = _build_parser().parse_args(argv) + + if args.command == 'git-log-format': + _emit(GIT_LOG_FORMAT) + return 0 + + categories = _categories(args, sys.stdin.read()) + + if args.command == 'bump-size': + _emit(infer_bump_size(categories)) + elif args.command == 'next-version': + try: + _emit(next_version(previous=args.previous, size=infer_bump_size(categories))) + except ValueError as exc: + print(f'changelog: {exc}', file=sys.stderr) + return 2 + elif args.command == 'release-notes': + _emit(format_release_notes(categories, args.compare_url, repo=args.repo)) + else: + _emit(format_changes(categories, args.tag, args.date or _today())) + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py new file mode 100644 index 0000000..a27a985 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -0,0 +1,209 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""The changelog format, as constants. + +None of this is injectable, and that is the point. The format is the same +across the Charm Tech repositories, and the set of conventional-commit types +is enforced by a shared CI check, so there is no second format for an +adopting repository to supply. A hook or a template system here would exist +for a caller that does not exist. +""" + +from __future__ import annotations + +import re + +#: How a pull-request link is rebuilt from a number. A change carries its +#: *number*, because that is all the git log carries and all a ``CHANGES.md`` +#: entry renders, so the URL a release body wants is built back up from the +#: number and the repository the caller names. That is a string operation, +#: which is what keeps the package free of I/O. +PULL_REQUEST_URL_TEMPLATE = 'https://github.com/{repo}/pull/{number}' + +#: The prefix of the compare line a release body ends with. The link itself +#: is the caller's (`--compare-url`); this is the shape GitHub uses, so that +#: notes rendered here read the same as notes rendered there. +FULL_CHANGELOG_PREFIX = '**Full Changelog**' + +#: The ``git log --format=`` string `parse_git_log` expects, and the two +#: control characters it is built out of. +#: +#: The fields are the *author* name and email, the subject, and the body. +#: Author rather than committer: a squash merge records the contributor as +#: the author and GitHub itself as the committer, so the committer is never +#: the person to credit. There is no SHA, deliberately -- a squashed commit's +#: SHA on the default branch has no relation to anything a contributor would +#: cite, and the pull-request number in the subject is the key that does. +#: +#: The separators are ASCII 0x1e (record) and 0x1f (unit), which is what they +#: are for. A commit message may contain anything else, newlines and blank +#: lines very much included, so a line-oriented or blank-line-delimited format +#: would be guessing at where one commit stops and the next starts:: +#: +#: git log --reverse --no-merges --format="$FORMAT" 3.8.1..3.8.2 +#: +#: ``--reverse`` because a changelog reads oldest first, which is also the +#: order GitHub's generated notes come in. ``--no-merges`` because a merge +#: commit's subject is not a conventional-commit one; such a subject is +#: dropped anyway, so this is tidiness rather than correctness. +GIT_LOG_RECORD_SEPARATOR = '\x1e' +GIT_LOG_FIELD_SEPARATOR = '\x1f' +GIT_LOG_FORMAT = '%x1e%an%x1f%ae%x1f%s%x1f%b' + +#: A conventional-commit subject, as the shared `check-conventional-pr-title` +#: script defines it: a type, an optional scope, an optional ``!``, then the +#: summary. +#: +#: The scope is captured and dropped. `canonical/pebble` uses scopes heavily +#: -- 60 of its last 298 conventional subjects carry one, from +#: ``chore(deps)`` to ``fix(cmdstate,wsutil)`` -- so this is a choice about +#: what a changelog entry should read like, not an observation that nothing +#: uses them. A reader of a release's notes wants what changed; which package +#: it changed in is in the diff, and prefixing every bullet with it would +#: mean a `chore(deps)`-heavy range rendering sixty near-identical prefixes. +#: If a repository ever wants them rendered, `Change` is where the scope +#: would have to be carried, and this is the only place it is currently +#: thrown away. +COMMIT_SUBJECT_REGEX = re.compile( + r'^(?P[A-Za-z]+)' + r'(?:\((?P[^()]+)\))?' + r'(?P!?)' + r': (?P.+)$' +) + +#: The ``(#123)`` that a squash merge appends to the subject, and the only +#: place the pull-request number comes from on the git-log path. Over +#: `canonical/operator`'s last 300 commits every subject carries one; the +#: commits that do not are older, from before the squash-merge policy, and a +#: commit pushed straight to the default branch would not have one either. +#: Such a change is real and belongs in the changelog, so it is carried with +#: no number rather than with a placeholder that hides it. +PR_SUFFIX_REGEX = re.compile(r'\s*\(#(\d+)\)$') + +#: What GitHub's "Revert" button writes into the body of the revert pull +#: request: ``Reverts canonical/operator#2538``. It names the *pull request* +#: rather than a commit, which is the robust key under squash merging, since +#: the reverted commit's SHA on the default branch is not something anyone +#: cites. The owner/repo part is optional because a body written by hand +#: often leaves it off. +REVERTS_REGEX = re.compile( + r'^[ \t]*Reverts[ \t]+(?:(?P[\w.-]+/[\w.-]+))?#(?P\d+)[ \t]*$', + flags=re.MULTILINE | re.IGNORECASE, +) + +#: A GitHub no-reply address, which is the one author email a handle can be +#: recovered from: ``46688206+Ali-932@users.noreply.github.com`` is +#: ``@Ali-932``. It is the default for a GitHub account with a private email, +#: so it is the common form for exactly the drive-by external contributor +#: this is here to credit. The older suffix-free form is accepted too, and so +#: is the ``[bot]`` a GitHub App's address carries. +NOREPLY_EMAIL_REGEX = re.compile( + r'^(?:\d+\+)?(?P[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d]))*(?:\[bot\])?)' + r'@users\.noreply\.github\.com$', + flags=re.IGNORECASE, +) + +#: The categories a changelog has, in the order they are rendered. +#: +#: This is also the filter. A conventional-commit type that is not a key here +#: is dropped from the changelog entirely, and `chore` is the type that makes +#: that matter: it is a real type, accepted by the PR-title check, but it is +#: deliberately not a category. Dependency bumps, charm-pin updates and the +#: release's own version-bump commit are all `chore`, and none of them is +#: something a reader of a changelog is looking for. In a typical operator +#: release that is a third to a half of the commits in the range. Dropping +#: them is the intended behaviour, not an oversight in the type list, so +#: please do not "fix" it by adding a `chore` key. +#: +#: `breaking` goes the other way round: it is a key here but is not a +#: conventional-commit type, so nothing ever parses into it directly. A `!` +#: after the real type moves an entry into it instead, keeping its real type +#: as a prefix (`Feat: ...`), and it renders first. +CATEGORIES: tuple[str, ...] = ( + 'breaking', + 'feat', + 'fix', + 'docs', + 'test', + 'refactor', + 'perf', + 'ci', + 'revert', +) + +#: The meta category breaking changes are collected into. +BREAKING = 'breaking' + +#: The one real conventional-commit type the bump-size rule cares about. +FEATURE = 'feat' + +#: The type of a commit that undoes another one. +REVERT = 'revert' + +#: Reverting one of these is a removal of behaviour people may already be +#: relying on, so the revert is routed to `BREAKING` rather than left under +#: `REVERT`. Only a revert of something *already released* gets this far: a +#: revert of a change in the same range cancels with it and neither appears. +#: +#: `feat` is the case that matters, and it is the reason this exists at all: +#: a released feature that is taken away again is a breaking change by any +#: reading, and filing it under `Reverted` would put it below the fold of a +#: changelog that the affected reader needs to see the top of. An inner `!` +#: is treated the same way, for the same reason and more obviously. +REVERT_OF_BREAKING_TYPES: tuple[str, ...] = (FEATURE,) + +#: The categories whose presence in a range makes the release a minor one. +#: Everything else -- and an empty range -- is a patch. +#: +#: `feat` is the rule as it is usually stated. `breaking` is here because a +#: `!` moves an entry *out* of its real type and into the meta category, so +#: a range whose only feature is a `feat!` has an empty `feat` list, and a +#: rule that only looked at `feat` would call that release a patch. It is +#: also the right answer in its own right: a `!` does not infer a major bump +#: (see `BREAKING_PREAMBLE`), but a breaking change riding in a *patch* +#: release is a worse bend of the rules than one riding in a minor, which is +#: the bend we have actually decided to allow. operator's own 3.8.0 shipped +#: a `refactor!` in a minor release. +#: +#: A major bump is never inferred, from this or anything else. That is what +#: the release workflow's explicit version input is for. +MINOR_BUMP_CATEGORIES: tuple[str, ...] = (BREAKING, FEATURE) + +#: A plain `X.Y.Z` release version, which is the only shape the bump +#: arithmetic will touch. Pre-releases, dev versions and anything else are +#: the caller's own policy: see `next_version`. +RELEASE_VERSION_REGEX = re.compile(r'(\d+)\.(\d+)\.(\d+)') + +#: Commit type to the heading it is rendered under. A type with no entry +#: here is capitalised instead, which is what makes an unrecognised type +#: degrade to something readable rather than to a KeyError. +CATEGORY_HEADINGS = { + 'feat': 'Features', + 'fix': 'Fixes', + 'docs': 'Documentation', + 'test': 'Tests', + 'ci': 'CI', + 'perf': 'Performance', + 'refactor': 'Refactoring', + 'revert': 'Reverted', + 'breaking': 'Breaking Changes', +} + +#: The sentence under the release notes' `### Breaking Changes` heading. A +#: `!` deliberately does not infer a major version bump -- a breaking change +#: sometimes rides in a minor release -- so this calling-out is what the bent +#: rule relies on. +BREAKING_PREAMBLE = 'There are breaking changes in this release. Please review them carefully:' diff --git a/changelog/src/charm_tech_code/changelog/_format.py b/changelog/src/charm_tech_code/changelog/_format.py new file mode 100644 index 0000000..14ad479 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_format.py @@ -0,0 +1,140 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Rendering parsed categories as release notes and as a changelog entry.""" + +from __future__ import annotations + +import datetime +import logging +from collections.abc import Mapping + +from ._constants import ( + BREAKING, + BREAKING_PREAMBLE, + CATEGORY_HEADINGS, + FULL_CHANGELOG_PREFIX, + PULL_REQUEST_URL_TEMPLATE, +) +from ._models import Change + +logger = logging.getLogger(__name__) + + +def commit_type_to_category(commit_type: str) -> str: + """Map a commit type to a human-readable category heading. + + If the commit type is not recognised, it returns the capitalised commit type. + """ + return CATEGORY_HEADINGS.get(commit_type, commit_type.capitalize()) + + +def _bullet(change: Change, reference: str | None) -> str: + """One `* ...` line: the change, who to thank, and where it came from. + + The three pieces are each optional after the first, and a missing one + takes its separator with it rather than leaving `by (#)` behind. The + credit sits before the reference because that is where + `canonical/operator`'s hand-written entries have always put it: + `* Fix typos in code snippets by @MattiaSarti (#1750)`. + """ + parts = [f'* {change.description}'] + if change.credit: + parts.append(f'by {change.credit}') + if reference: + parts.append(reference) + return ' '.join(parts) + + +def format_release_notes( + categories: Mapping[str, list[Change]], compare_url: str | None, *, repo: str +) -> str: + """Format for release notes. + + Results in a Markdown formatted string with sections for each commit type. + + Breaking changes are rendered first, under their own heading and a + sentence asking the reader to review them. `categories` is expected to be + what `parse_git_log` returned: every category present, in the order they + are rendered in. + + Args: + categories: The parsed changes. + compare_url: A link comparing the two ends of the range, rendered as + the closing line. A git log does not carry one, and the tags at + either end are the caller's to know. `None` for no closing line. + repo: The `owner/name` the pull-request links point into. It is + needed because a `Change` carries a number and not a URL -- the + number is all a git log has, and all a `CHANGES.md` entry shows, + so the link is built here rather than carried around. A change + with no pull request renders with no link. + + """ + lines = ["## What's Changed", ''] + if categories[BREAKING]: + lines.append(f'### {commit_type_to_category(BREAKING)}') + lines.append(f'{BREAKING_PREAMBLE}\n') + lines.extend(_bullet(change, _link(change, repo)) for change in categories[BREAKING]) + lines.append('') + logger.info( + 'Breaking changes detected in the release notes. ' + 'Please ensure there are sufficient instructions for users to handle them.' + ) + for commit_type, items in categories.items(): + if commit_type == BREAKING: + continue + if items: + lines.append(f'### {commit_type_to_category(commit_type)}') + lines.extend(_bullet(change, _link(change, repo)) for change in items) + lines.append('') + if compare_url: + lines.append(f'{FULL_CHANGELOG_PREFIX}: {compare_url}') + return '\n'.join(lines) + + +def _link(change: Change, repo: str) -> str | None: + """Render the `in ` half of a release-notes bullet, or nothing.""" + if change.pr_number is None: + return None + return 'in ' + PULL_REQUEST_URL_TEMPLATE.format(repo=repo, number=change.pr_number) + + +def format_changes(categories: Mapping[str, list[Change]], tag: str, date: datetime.date) -> str: + """Format for CHANGES.md. + + The header is formatted as a top-level heading with the tag and date. + The content is a Markdown formatted string with sections for each commit type. + Each item is formatted as a bullet point with the description and PR number in parentheses. + + A change with no pull request behind it -- a commit pushed straight to + the branch -- gets no `(#N)` rather than a `(#?)` standing in for one. + The change is real and belongs in the list; what it does not have is a + pull request to point anyone at, and saying so plainly beats a + placeholder that reads like a parsing accident. + + `date` is passed in rather than read from the clock. This module does no + I/O of any kind, and "what day is it" is I/O: the caller knows whether it + means the runner's today, the date on the tag, or a date under test. + """ + day = date.strftime('%d %B %Y') + lines = [f'# {tag} - {day}\n'] + for commit_type, items in categories.items(): + if items: + lines.append(f'## {commit_type_to_category(commit_type)}\n') + for change in items: + reference = None if change.pr_number is None else f'(#{change.pr_number})' + lines.append(_bullet(change, reference)) + lines.append('') + return '\n'.join(lines) + '\n' diff --git a/changelog/src/charm_tech_code/changelog/_models.py b/changelog/src/charm_tech_code/changelog/_models.py new file mode 100644 index 0000000..23ac9b6 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_models.py @@ -0,0 +1,50 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""What a parser produces and a formatter renders: one change. + +There is one of these per bullet. `parse_git_log` makes them and everything +downstream -- the two formatters, the bump-size rule -- works on this rather +than on the shape of the text they were read out of. +""" + +from __future__ import annotations + +from typing import NamedTuple + + +class Change(NamedTuple): + """One entry in a changelog. + + Attributes: + description: The summary, with its conventional-commit type stripped + and its first letter capitalised. A change routed into the + `breaking` category keeps its real type as a prefix, so this may + read `Feat: add the thing` or `Revert: "feat: add the thing"`. + pr_number: The pull request this came from, or `None` for a commit + that has none -- one pushed straight to the branch, or a release + notes bullet whose link is not a pull request. `None` is carried + rather than a placeholder because a change with no pull request + is still a real change, and rendering it as `(#?)` hides it in + plain sight. + credit: How to credit the author, already rendered: `@handle` where + one is known, the person's name where it is not, and `None` for + an author the caller named as one of its own. See `_authors`. + + """ + + description: str + pr_number: int | None = None + credit: str | None = None diff --git a/changelog/src/charm_tech_code/changelog/_parse.py b/changelog/src/charm_tech_code/changelog/_parse.py new file mode 100644 index 0000000..447414f --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_parse.py @@ -0,0 +1,255 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Reading a range of changes out of text, into categories. + +`parse_git_log` is the way in. The conventional-commit convention governs +*commits*, so the commits are what a changelog should be read off, and a +squashed subject is what lands on the branch and what everything else in the +repository reads. + +Three things that shapes: + +* **Reverts resolve.** Working out whether a revert cancels something in the + same range means reading the revert commit's *body*, which is in the log. + See `_cancelled`. +* **Authors are a name and an email.** A handle is sometimes recoverable from + the email and sometimes not, so a contributor is credited by whichever is + there. See `_authors`. +* **There is no compare link.** A release body ends with one; a git log has + no equivalent, so the caller supplies it (`--compare-url`) if it wants one. +""" + +from __future__ import annotations + +from collections.abc import Collection +from typing import NamedTuple + +from ._authors import credit_for +from ._constants import ( + BREAKING, + CATEGORIES, + COMMIT_SUBJECT_REGEX, + GIT_LOG_FIELD_SEPARATOR, + GIT_LOG_RECORD_SEPARATOR, + PR_SUFFIX_REGEX, + REVERT, + REVERT_OF_BREAKING_TYPES, + REVERTS_REGEX, +) +from ._models import Change + + +def _empty_categories() -> dict[str, list[Change]]: + """Every category, in render order, whether or not the range filled it. + + Callers index `categories['breaking']` and iterate the dict for the + order, so the shape must not depend on what happened to be released. + """ + return {category: [] for category in CATEGORIES} + + +def _capitalise(summary: str) -> str: + """Sentence-case a summary without disturbing what it starts with. + + Conventional-commit summaries are lower case after the type and + changelog bullets are sentence case, but a summary starting with a + backtick or a quotation mark must come through untouched. + """ + return summary[0].upper() + summary[1:] if summary else summary + + +class _Commit(NamedTuple): + """One record of a `GIT_LOG_FORMAT` log, taken apart.""" + + category: str + breaking: bool + description: str + pr_number: int | None + credit: str | None + #: The pull request this commit reverts, where it says so in its body. + reverts: int | None + #: The conventional-commit type of the thing being reverted, read out of + #: the quoted subject a revert carries: `revert: "feat: ..."` is `feat`. + reverted_type: str | None + reverted_type_is_breaking: bool + + +def _parse_reverts(body: str, repo: str | None) -> int | None: + """Find the pull-request number a revert commit's body names, if any. + + A ``Reverts other/repo#5`` naming a different repository is not this + range's #5, and cancelling against it would drop the wrong pair, so it + is ignored when the caller has said which repository this log is from. + With no `repo` to check against there is nothing to compare, and the + reference is taken at face value. + """ + match = REVERTS_REGEX.search(body) + if not match: + return None + named_repo = match.group('repo') + if repo is not None and named_repo is not None and named_repo.casefold() != repo.casefold(): + return None + return int(match.group('number')) + + +def _parse_commit(record: str, team: Collection[str], repo: str | None) -> _Commit | None: + """One `GIT_LOG_FORMAT` record, or `None` if it is not a change. + + A subject that is not a conventional-commit one -- a merge commit, or + anything from before the convention was adopted -- is not a changelog + entry and is dropped here, the same way the notes parser drops a line + that is not a bullet. + """ + name, _, rest = record.partition(GIT_LOG_FIELD_SEPARATOR) + email, _, rest = rest.partition(GIT_LOG_FIELD_SEPARATOR) + subject, _, body = rest.partition(GIT_LOG_FIELD_SEPARATOR) + + subject = subject.strip() + pr_number = None + if suffix := PR_SUFFIX_REGEX.search(subject): + pr_number = int(suffix.group(1)) + subject = subject[: suffix.start()] + + match = COMMIT_SUBJECT_REGEX.match(subject) + if not match: + return None + + summary = match.group('summary').strip() + reverted_type = None + reverted_type_is_breaking = False + if match.group('category').casefold() == REVERT: + # `revert: "feat: add the thing"` -- the quoted subject is the one + # being undone, and its type says how much undoing it matters. The + # quotes are what GitHub's Revert button writes, but a hand-written + # revert often leaves them off, so both are read. + inner = COMMIT_SUBJECT_REGEX.match(summary.strip('"').strip()) + if inner: + reverted_type = inner.group('category').casefold() + reverted_type_is_breaking = inner.group('breaking') == '!' + + return _Commit( + category=match.group('category').casefold(), + breaking=match.group('breaking') == '!', + description=_capitalise(summary), + pr_number=pr_number, + credit=credit_for(name, email, team), + reverts=_parse_reverts(body, repo), + reverted_type=reverted_type, + reverted_type_is_breaking=reverted_type_is_breaking, + ) + + +def _cancelled(commits: list[_Commit]) -> set[int]: + """Find the commits that a revert in the same range takes back out of it. + + A change that landed and was undone before anything shipped did not + happen as far as a reader is concerned, so neither half appears: not the + change, and not the revert of it either. Listing both would be accurate + and useless, and listing the revert alone would describe the removal of + something the changelog never said had arrived. + + Identity is the pull-request number, because that is what a revert body + names and, under squash merging, the only stable thing it could name. + + Returns: + The indices into `commits` to leave out. + + """ + numbers = {commit.pr_number: index for index, commit in enumerate(commits) if commit.pr_number} + cancelled: set[int] = set() + for index, commit in enumerate(commits): + if commit.reverts is not None and commit.reverts in numbers: + cancelled.add(index) + cancelled.add(numbers[commit.reverts]) + return cancelled + + +def parse_git_log( + log_text: str, *, team: Collection[str] = (), repo: str | None = None +) -> dict[str, list[Change]]: + """Parse a range of commits into categories. + + This is the input to prefer. The conventional-commit convention is about + commit subjects, `canonical/operator` squash-merges so that every subject + on a release branch is `type: summary (#N)`, and reading the subjects is + therefore reading the thing the convention actually governs. It also + needs nothing from GitHub: the pull-request number comes out of the + `(#N)` suffix, and the link a release body wants is built back up from + that number and `repo` when it is rendered. + + `log_text` is the output of ``git log`` with ``--format=GIT_LOG_FORMAT``, + oldest first. Getting it is the caller's job: nothing here runs git, or + anything else. + + Three things the subjects and bodies make possible: + + * **Reverts cancel.** A revert whose pull request is also in this range + removes both itself and what it reverted. See `_cancelled`. + * **A revert of something already released is called out.** It keeps its + own `Reverted` heading rather than being filed under the type it + undoes, so that it reads as a removal and not as a new fix. A revert of + a released feature is a withdrawal of behaviour, so it goes further and + is routed to `breaking`; see `REVERT_OF_BREAKING_TYPES`. + * **A commit with no `(#N)` keeps a `None`**, rather than a placeholder, + so that a change pushed straight to the branch is visible as one. + + Args: + log_text: `git log` output in `GIT_LOG_FORMAT`. + team: Authors not to credit, as emails and/or handles. The default + credits everyone; see `_authors`. + repo: The `owner/name` this log came from, used only to ignore a + `Reverts` line that names a different repository. + + Returns: + A dict of category to `Change` list, in the order they are rendered + in, with every category present even when empty. There is no second + return value: a git log carries no compare link for the formatter to + pass through, and inventing one would mean knowing the tags at both + ends, which is the caller's business. + + """ + records = log_text.split(GIT_LOG_RECORD_SEPARATOR) + commits: list[_Commit] = [] + for record in records: + if not record.strip(): + continue + commit = _parse_commit(record, team, repo) + if commit is not None: + commits.append(commit) + + categories = _empty_categories() + cancelled = _cancelled(commits) + for index, commit in enumerate(commits): + if index in cancelled or commit.category not in categories: + continue + change = Change(commit.description, commit.pr_number, commit.credit) + breaking = commit.breaking or ( + commit.category == REVERT + and ( + commit.reverted_type in REVERT_OF_BREAKING_TYPES + or commit.reverted_type_is_breaking + ) + ) + if breaking: + categories[BREAKING].append( + change._replace( + description=f'{commit.category.capitalize()}: {change.description}' + ) + ) + else: + categories[commit.category].append(change) + + return categories diff --git a/changelog/src/charm_tech_code/changelog/_version.py b/changelog/src/charm_tech_code/changelog/_version.py new file mode 100644 index 0000000..2d9d04c --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_version.py @@ -0,0 +1,116 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Size a release, and say what version it makes. + +How big the commits in a range add up to, and what that makes the next +version. + +Two functions, and the split between them is the interesting part. The +Charm Tech repositories share a changelog format and a set of +conventional-commit types, so "is this range a minor or a patch" has one +answer everywhere and lives here. What the *next version* of a particular +repository is does not: which version to count from, whether a `.dev0` gets +appended afterwards, what a pre-release looks like, and canonical/operator's +rule that the ops-scenario major is the ops major plus five, are all things +the adopting repository decides. `next_version` is the generic middle: it +applies a size to a plain `X.Y.Z` and refuses anything else, so a repository +with a version shape of its own has to say what it means rather than get a +silently wrong answer. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal + +from ._constants import MINOR_BUMP_CATEGORIES, RELEASE_VERSION_REGEX +from ._models import Change + +#: The two sizes a release can be inferred to be. A major bump is never +#: inferred -- see `MINOR_BUMP_CATEGORIES` -- so it is not one of these. +BumpSize = Literal['minor', 'patch'] + +MINOR: BumpSize = 'minor' +PATCH: BumpSize = 'patch' + + +def infer_bump_size(categories: Mapping[str, list[Change]]) -> BumpSize: + """Work out whether a range of changes is a minor release or a patch one. + + `categories` is what `parse_git_log` returned. + That matters rather more than it looks: the parse is where a `!` moves an + entry out of its real type and into `breaking`, and where a revert of a + released feature is routed there too, so the categories this reads have + already had that routing applied. Passing a dict built some other way + will get a different answer. + + A revert is a change like any other here, with one thing worth saying: + a revert of something released earlier counts, at least as a patch, + because taking a change back out is itself a change that shipped. A + revert of something in this same range counts for nothing, because + `parse_git_log` has already cancelled the pair and neither is in + `categories` to be counted. + + A `minor` result means "there is a feature, or a breaking change, in this + range". A caller that has branches on which neither may appear -- a + maintenance branch carrying only cherry-picked fixes, say -- should treat + `minor` as the error it is for that branch, rather than quietly bumping + the patch instead. This function will not do that for you: which branches + those are is repository policy, and nothing here knows what branch it is + on. + + Returns: + `'minor'` or `'patch'`. Never `'major'`: a major release is a + deliberate act, not something to infer from a commit range. + + """ + if any(categories.get(category) for category in MINOR_BUMP_CATEGORIES): + return MINOR + return PATCH + + +def next_version(*, previous: str, size: BumpSize) -> str: + """Apply a bump size to a released version. + + `previous` is the version this release follows, which for a workflow + proposing a release is normally the last tag on the branch being released + -- not whatever is currently in the repository's version file, which by + then is usually a `.dev0` of a version that was only ever a guess. + + Only a plain `X.Y.Z` is accepted. A pre-release, a dev version or a local + version means the caller is doing something this cannot infer -- cutting + `3.9.0b1`, or resuming a pre-release series -- and the release workflow's + explicit version input is the way to say so. Raising is the point: the + alternative is quietly dropping a suffix and tagging the wrong thing. + + Raises: + ValueError: if `previous` is not a plain `X.Y.Z`, or `size` is not + one of the two sizes `infer_bump_size` returns. + + """ + match = RELEASE_VERSION_REGEX.fullmatch(previous.strip()) + if not match: + raise ValueError( + f'{previous!r} is not a plain X.Y.Z release version. Pre-releases, ' + f'dev versions and anything else are not inferred from: pass the ' + f'version you want explicitly.' + ) + major, minor, patch = (int(part) for part in match.groups()) + if size == MINOR: + return f'{major}.{minor + 1}.0' + if size == PATCH: + return f'{major}.{minor}.{patch + 1}' + raise ValueError(f'{size!r} is not a bump size. Expected {MINOR!r} or {PATCH!r}.') diff --git a/changelog/tests/conftest.py b/changelog/tests/conftest.py new file mode 100644 index 0000000..aa24dcb --- /dev/null +++ b/changelog/tests/conftest.py @@ -0,0 +1,66 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Nothing in this package's library may read the clock. + +A library function that reads the clock makes the output of a release depend +on which day CI happened to run, and makes the function impossible to assert +on. So the date is an argument, and this fixture is what keeps it one: it +replaces the `datetime` module as each library module sees it, so a `now()` +or `today()` call anywhere under it fails the whole suite rather than quietly +passing on every day except the one that matters. + +`_cli` is deliberately not in the list. A console script has to get a date +from somewhere for `--date` to be optional, so it is the package's I/O +boundary and the one module allowed to look: see its `_today`. Everything it +calls is still inside the fixture, so the boundary cannot drift inwards +without a test failing. +""" + +from __future__ import annotations + +import pytest + +from charm_tech_code.changelog import _authors, _constants, _format, _models, _parse, _version + + +class _NoClock: + """Stands in for `datetime.datetime` and `datetime.date`.""" + + @staticmethod + def now(*args: object, **kwargs: object): + raise AssertionError( + 'The clock was read by the package itself. `format_changes` takes ' + 'a date argument precisely so that it does not do this.' + ) + + today = now + utcnow = now + + +class _NoClockModule: + datetime = _NoClock + date = _NoClock + + +#: Every module of the library, whether or not it imports `datetime` today. +#: `raising=False` below means a module that does not import it is covered in +#: advance rather than having to be remembered when it does. +LIBRARY_MODULES = (_authors, _constants, _format, _models, _parse, _version) + + +@pytest.fixture(autouse=True) +def no_clock(monkeypatch: pytest.MonkeyPatch) -> None: + for module in LIBRARY_MODULES: + monkeypatch.setattr(module, 'datetime', _NoClockModule, raising=False) diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py new file mode 100644 index 0000000..dbaf2d7 --- /dev/null +++ b/changelog/tests/test_changelog.py @@ -0,0 +1,1104 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the changelog package. + +The specification here is what `canonical/operator`'s `release.py` does +against real input, so the large fixtures are real operator releases rather +than invented ones. The notes fixtures and the commit fixtures describe the +same pull requests from the two ends: `git log --reverse 3.8.1..3.8.2` gives +the merged pull requests in the range, in merge order, and GitHub's generated +notes are one `* by @ in ` bullet each, in that same +order, with the PR title being the squash-commit subject minus its ` (#NNNN)` +suffix. + +**Which fixtures are observed and which are constructed** matters enough to +say per fixture, and each one says so. The short version: everything taken +from `canonical/operator` is observed, down to the author emails, except the +two bot handles in the 3.8.2 notes; and the revert cases in `RevertTests` +are constructed, because operator's history contains exactly one revert and +it reverts a `chore`, which the changelog drops anyway. + +Commit *bodies* are the one thing elided from the observed fixtures. A real +`git log` body is the whole pull-request description, which is kilobytes per +commit and none of which is read except a `Reverts owner/repo#N` line, so the +fixtures carry the bodies that matter and empty strings everywhere else. +""" + +from __future__ import annotations + +import contextlib +import datetime +import io +import pathlib +import unittest +from unittest import mock + +from charm_tech_code.changelog import ( + CATEGORIES, + CATEGORY_HEADINGS, + GIT_LOG_FORMAT, + MINOR, + MINOR_BUMP_CATEGORIES, + PATCH, + Change, + _cli, + commit_type_to_category, + format_changes, + format_release_notes, + infer_bump_size, + next_version, + parse_git_log, +) + +# The Charm Tech team as `canonical/operator` would supply it: emails, which +# is what a git log carries, and handles, which is what release notes carry. +# Observed -- the handles are the team list in the staging tree's AGENTS.md +# and the emails are the ones in operator's own history. Two bot addresses +# are in here as well, because a bot is not a contributor to thank either; +# nothing in these tests depends on that, since every bot commit in the +# fixtures is a `chore` and dropped before it could be credited. +OPERATOR_TEAM = ( + '@benhoyt', + '@dwilding', + '@hpidcock', + '@james-garner-canonical', + '@tromai', + '@tonyandrewmeyer', + 'ben.hoyt@canonical.com', + 'david.wilding@canonical.com', + 'tony.meyer@canonical.com', + 'trongnhan.mai@canonical.com', + 'ben.hoyt+prints-charming-bot@canonical.com', + '49699333+dependabot[bot]@users.noreply.github.com', +) + +#: The repository every fixture here comes from, and so the one the +#: pull-request links are built against. +REPO = 'canonical/operator' + +TONY = ('Tony Meyer', 'tony.meyer@canonical.com') +DAVID = ('Dave Wilding', 'david.wilding@canonical.com') +NHAN = ('Trong Nhan Mai', 'trongnhan.mai@canonical.com') +DEPENDABOT = ('dependabot[bot]', '49699333+dependabot[bot]@users.noreply.github.com') +PRINTS_CHARMING = ('Prints Charming', 'ben.hoyt+prints-charming-bot@canonical.com') +# Three shapes of contributor from outside the team, all observed in +# operator's history, and the reason `_authors` is not two lines long: +# Ali-932 and gcomneno commit under GitHub no-reply addresses, so a handle +# falls out of the email; Iya Mg does not, so there is no handle to be had +# and the name is the credit. +ALI = ('Ali Al-Obaidi', '46688206+Ali-932@users.noreply.github.com') +GCOMNENO = ('Giancarlo Cicellyn Comneno', '126195429+gcomneno@users.noreply.github.com') +IYA = ('Iya Mg', 'dev@iyamg.com') + + +def git_log(*commits: tuple[tuple[str, str], str, str]) -> str: + """Build `GIT_LOG_FORMAT` text out of (author, subject, body) triples. + + This is what `git log --format="$(changelog git-log-format)"` would have + written, assembled here so that a fixture can be read: the separators are + control characters, and a checked-in file full of them is not reviewable. + `test_the_fixture_format_is_the_format_git_is_asked_for` pins the two + against each other so this cannot drift into testing a private dialect. + """ + return ''.join( + f'\x1e{name}\x1f{email}\x1f{subject}\x1f{body}' for (name, email), subject, body in commits + ) + + +# The same twenty-three pull requests as they appear in `git log --reverse +# 3.8.1..3.8.2`: author name, author email and squash-commit subject, all +# verbatim. This is the other end of the fixture above, and the pair is what +# `SameRangeFromEitherInputTests` compares. +OPERATOR_3_8_2_COMMITS = ( + (TONY, 'chore: adjust versions after release (#2670)', ''), + (TONY, 'docs: give each best-practice admonition a stable :name: anchor (#2524)', ''), + (TONY, 'ci: point DB charm CI at the moved mysql-operators repo (#2551)', ''), + (DEPENDABOT, 'chore: bump cryptography from 48.0.1 to 50.0.0 (#2682)', ''), + (ALI, 'fix: compare full event paths when skipping duplicate notices (#2684)', ''), + (DEPENDABOT, 'chore: bump the actions group across 1 directory with 8 updates (#2674)', ''), + (DEPENDABOT, 'chore: bump the runtime group across 1 directory with 4 updates (#2691)', ''), + (TONY, 'docs: reword text that vale 3.17 flags as misspelled (#2695)', ''), + (PRINTS_CHARMING, 'chore: update charm pins (#2582)', ''), + ( + DEPENDABOT, + 'chore: bump the dev-tooling group in /examples/httpbin-demo with 2 updates (#2675)', + '', + ), + (DEPENDABOT, 'chore: bump the charm-tech group across 1 directory with 3 updates (#2697)', ''), + (TONY, 'docs: stop styling page references as blockquotes (#2666)', ''), + (DAVID, 'ci: switch example charm integration tests to Concierge `k8s` preset (#2696)', ''), + (TONY, 'docs: make the custom-endpoint-name sample test actually test something (#2664)', ''), + (TONY, 'ci: use the upstream concierge presets again (#2699)', ''), + (DAVID, 'docs: replace `requests` by `urllib` in K8s tutorial integration tests (#2687)', ''), + (TONY, "fix: don't pass a message when converting an unknown status by name (#2700)", ''), + (DEPENDABOT, 'chore: bump the dev-tooling group with 4 updates (#2676)', ''), + (PRINTS_CHARMING, 'chore: update charm pins (#2701)', ''), + (TONY, "chore: adopt ruff 0.16's new lint conventions (#2698)", ''), + (TONY, 'docs: recommend spread directly, rather than charmcraft test (#2706)', ''), + ( + NHAN, + 'docs: extract sections in how to write integration tests to their own howto' + ' guide (#2662)', + '', + ), + (DAVID, 'chore: update changelog and versions for 3.8.2 release (#2716)', ''), +) + +# The PR numbers of the eleven `chore` pull requests in that release. None of +# them may appear anywhere in either rendered output. +OPERATOR_3_8_2_CHORE_PRS = ( + '2670', + '2682', + '2674', + '2691', + '2582', + '2675', + '2697', + '2676', + '2701', + '2698', + '2716', +) + + +# The same four pull requests as commits. Observed, and trimmed to the same +# four so that this pairs with the notes fixture above. +OPERATOR_BREAKING_COMMITS = ( + (TONY, 'refactor: replace jsonpatch with an inline dict-diff (#2578)', ''), + ( + TONY, + 'refactor!: move the otlp-json package to be a regular ops-tracing module (#2585)', + '', + ), + (TONY, 'feat: note the socket path in Pebble tracing spans (#2555)', ''), + (TONY, 'fix: tear down `Runtime.exec()` when the charm raises (#2581)', ''), +) + +# Six real commits from the 3.7.1..3.8.0 range, in merge order, chosen for +# what they carry rather than trimmed for length: the only revert operator +# has merged into a 3.x release (#2568) together with the pull request it +# reverts (#2538), and one of each shape of outside contributor. The body of +# #2568 is real too, down to the third line; every other body is elided. +# +# This range has no notes counterpart in these tests, deliberately. Two of +# the three contributors here have no handle in the git log at all, so the +# notes GitHub generated for 3.8.0 say something this fixture cannot be +# derived from, and inventing the handles would be inventing the answer to +# the question `AuthorCreditTests` asks. +OPERATOR_REVERT_RANGE_COMMITS = ( + (GCOMNENO, 'fix: treat remote unit zero as explicit (#2454)', ''), + (IYA, 'docs: fix small issues in K8s tutorial (#2540)', ''), + (TONY, 'docs: replace CoC with link to Ubuntu Code of Conduct (#2564)', ''), + (DEPENDABOT, 'chore: bump opentelemetry-api from 1.37.0 to 1.42.1 (#2538)', ''), + ( + TONY, + 'revert: "chore: bump opentelemetry-api from 1.37.0 to 1.42.1" (#2568)', + 'Reverts canonical/operator#2538\n\nTriggers warnings on 3.10.\n', + ), + ( + ('deusebio', 'edeusebio85@gmail.com'), + 'docs: add guidance about names of workload-less charms (#2496)', + '', + ), +) + +OPERATOR_3_8_2_LOG = git_log(*OPERATOR_3_8_2_COMMITS) +OPERATOR_BREAKING_LOG = git_log(*OPERATOR_BREAKING_COMMITS) +OPERATOR_REVERT_RANGE_LOG = git_log(*OPERATOR_REVERT_RANGE_COMMITS) + +#: `OPERATOR_TEAM` as a workflow would pass it: one repository variable. +TEAM_ARGUMENT = ','.join(OPERATOR_TEAM) + + +class RealReleaseTests(unittest.TestCase): + """The 3.8.2 fixture, end to end.""" + + #: A git log carries no compare link, so a caller that wants one supplies + #: it. This is what `changelog release-notes --compare-url` takes. + compare_url = 'https://github.com/canonical/operator/compare/3.8.1...3.8.2' + + def setUp(self): + self.categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) + + def test_categories(self): + # Twelve of the twenty-three pull requests survive, in merge order. + # Only #2684 carries a credit: its author is the one contributor in + # this range who is not on the team. + assert self.categories == { + 'breaking': [], + 'feat': [], + 'fix': [ + Change( + 'Compare full event paths when skipping duplicate notices', 2684, '@Ali-932' + ), + Change("Don't pass a message when converting an unknown status by name", 2700), + ], + 'docs': [ + Change('Give each best-practice admonition a stable :name: anchor', 2524), + Change('Reword text that vale 3.17 flags as misspelled', 2695), + Change('Stop styling page references as blockquotes', 2666), + Change('Make the custom-endpoint-name sample test actually test something', 2664), + Change('Replace `requests` by `urllib` in K8s tutorial integration tests', 2687), + Change('Recommend spread directly, rather than charmcraft test', 2706), + Change( + 'Extract sections in how to write integration tests to their own howto guide', + 2662, + ), + ], + 'test': [], + 'refactor': [], + 'perf': [], + 'ci': [ + Change('Point DB charm CI at the moved mysql-operators repo', 2551), + Change('Switch example charm integration tests to Concierge `k8s` preset', 2696), + Change('Use the upstream concierge presets again', 2699), + ], + 'revert': [], + } + + def test_chore_is_dropped(self): + # Eleven of the twenty-three pull requests are `chore`, and none of + # them reaches the output. Deliberate: dependency bumps, charm-pin + # updates and the release's own version bump are not changelog + # material. + notes = format_release_notes(self.categories, self.compare_url, repo=REPO) + entry = format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) + assert 'chore' not in notes.lower() + assert 'chore' not in entry.lower() + for pr in OPERATOR_3_8_2_CHORE_PRS: + assert pr not in notes, f'chore PR #{pr} leaked into the release notes' + assert pr not in entry, f'chore PR #{pr} leaked into the changelog entry' + + def test_new_contributors_section_is_dropped(self): + notes = format_release_notes(self.categories, self.compare_url, repo=REPO) + assert 'New Contributors' not in notes + assert 'made their first contribution' not in notes + + def test_changes_entry(self): + # This is the 3.8.2 entry as it appears in operator's CHANGES.md, + # with two differences. Three summaries were edited by hand after + # the fact (#2700 gained an "In `ops.testing`," prefix, #2524 lost + # its ":name:", and #2662 was reworded). And #2684 is credited here + # and was not there: the author is not on the team, `release.py` + # discarded the author of every entry, and nobody put this one back + # by hand -- which is the whole of the argument for doing it here. + # The section order, the bullet order within each section and the + # blank-line layout are all exactly what shipped. + assert ( + format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) + == """\ +# 3.8.2 - 31 August 2026 + +## Fixes + +* Compare full event paths when skipping duplicate notices by @Ali-932 (#2684) +* Don't pass a message when converting an unknown status by name (#2700) + +## Documentation + +* Give each best-practice admonition a stable :name: anchor (#2524) +* Reword text that vale 3.17 flags as misspelled (#2695) +* Stop styling page references as blockquotes (#2666) +* Make the custom-endpoint-name sample test actually test something (#2664) +* Replace `requests` by `urllib` in K8s tutorial integration tests (#2687) +* Recommend spread directly, rather than charmcraft test (#2706) +* Extract sections in how to write integration tests to their own howto guide (#2662) + +## CI + +* Point DB charm CI at the moved mysql-operators repo (#2551) +* Switch example charm integration tests to Concierge `k8s` preset (#2696) +* Use the upstream concierge presets again (#2699) + +""" + ) + + def test_release_notes(self): + assert ( + format_release_notes(self.categories, self.compare_url, repo=REPO) + == """\ +## What's Changed + +### Fixes +* Compare full event paths when skipping duplicate notices by @Ali-932 in https://github.com/canonical/operator/pull/2684 +* Don't pass a message when converting an unknown status by name in https://github.com/canonical/operator/pull/2700 + +### Documentation +* Give each best-practice admonition a stable :name: anchor in https://github.com/canonical/operator/pull/2524 +* Reword text that vale 3.17 flags as misspelled in https://github.com/canonical/operator/pull/2695 +* Stop styling page references as blockquotes in https://github.com/canonical/operator/pull/2666 +* Make the custom-endpoint-name sample test actually test something in https://github.com/canonical/operator/pull/2664 +* Replace `requests` by `urllib` in K8s tutorial integration tests in https://github.com/canonical/operator/pull/2687 +* Recommend spread directly, rather than charmcraft test in https://github.com/canonical/operator/pull/2706 +* Extract sections in how to write integration tests to their own howto guide in https://github.com/canonical/operator/pull/2662 + +### CI +* Point DB charm CI at the moved mysql-operators repo in https://github.com/canonical/operator/pull/2551 +* Switch example charm integration tests to Concierge `k8s` preset in https://github.com/canonical/operator/pull/2696 +* Use the upstream concierge presets again in https://github.com/canonical/operator/pull/2699 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2""" + ) + + def test_the_date_is_the_one_it_is_given(self): + entry = format_changes(self.categories, '3.8.2', datetime.date(2020, 1, 2)) + assert entry.startswith('# 3.8.2 - 02 January 2020\n') + + +class BreakingChangeTests(unittest.TestCase): + """A `!` moves an entry into its own category, keeping its real type.""" + + #: Supplied by the caller, the way `--compare-url` does: see + #: `RealReleaseTests`. + compare_url = 'https://github.com/canonical/operator/compare/3.7.1...3.8.0' + + def setUp(self): + self.categories = parse_git_log(OPERATOR_BREAKING_LOG, team=OPERATOR_TEAM, repo=REPO) + + def test_breaking_entry_keeps_its_real_type_as_a_prefix(self): + assert self.categories['breaking'] == [ + Change('Refactor: Move the otlp-json package to be a regular ops-tracing module', 2585) + ] + + def test_breaking_entry_is_not_also_in_its_own_type(self): + # The `!` moves the entry rather than copying it, so #2585 leaves + # `refactor` and #2578, which has no `!`, is all that is left there. + assert self.categories['refactor'] == [ + Change('Replace jsonpatch with an inline dict-diff', 2578) + ] + + def test_release_notes_put_breaking_first_with_a_warning(self): + assert ( + format_release_notes(self.categories, self.compare_url, repo=REPO) + == """\ +## What's Changed + +### Breaking Changes +There are breaking changes in this release. Please review them carefully: + +* Refactor: Move the otlp-json package to be a regular ops-tracing module in https://github.com/canonical/operator/pull/2585 + +### Features +* Note the socket path in Pebble tracing spans in https://github.com/canonical/operator/pull/2555 + +### Fixes +* Tear down `Runtime.exec()` when the charm raises in https://github.com/canonical/operator/pull/2581 + +### Refactoring +* Replace jsonpatch with an inline dict-diff in https://github.com/canonical/operator/pull/2578 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0""" + ) + + def test_changes_entry_puts_breaking_first_without_the_warning(self): + # The warning sentence belongs to the release notes only. A + # `CHANGES.md` entry is a list, and gets the heading alone. + assert ( + format_changes(self.categories, '3.8.0', datetime.date(2026, 6, 30)) + == """\ +# 3.8.0 - 30 June 2026 + +## Breaking Changes + +* Refactor: Move the otlp-json package to be a regular ops-tracing module (#2585) + +## Features + +* Note the socket path in Pebble tracing spans (#2555) + +## Fixes + +* Tear down `Runtime.exec()` when the charm raises (#2581) + +## Refactoring + +* Replace jsonpatch with an inline dict-diff (#2578) + +""" + ) + + +class FormatReleaseNotesTests(unittest.TestCase): + def empty(self) -> dict[str, list[Change]]: + return {category: [] for category in CATEGORIES} + + def test_empty_release(self): + assert format_release_notes(self.empty(), None, repo=REPO) == "## What's Changed\n" + + def test_categories_render_in_the_declared_order(self): + categories = self.empty() + for category in ('revert', 'ci', 'feat', 'fix'): + categories[category] = [Change(f'A {category} change', 1)] + headings = [ + line + for line in format_release_notes(categories, None, repo=REPO).splitlines() + if line.startswith('###') + ] + assert headings == ['### Features', '### Fixes', '### CI', '### Reverted'] + + def test_the_compare_url_becomes_the_closing_line(self): + # The caller passes the link; the prefix is the package's, so that + # notes rendered here read the same as notes rendered by GitHub. + notes = format_release_notes(self.empty(), 'https://example.com/x', repo=REPO) + assert notes.endswith('**Full Changelog**: https://example.com/x') + + +class FormatChangesTests(unittest.TestCase): + def entry(self, change: Change) -> str: + categories: dict[str, list[Change]] = {category: [] for category in CATEGORIES} + categories['fix'] = [change] + return format_changes(categories, '1.2.3', datetime.date(2026, 9, 10)) + + def test_the_pr_number_is_rendered_in_parentheses(self): + assert '* A fix (#2684)' in self.entry(Change('A fix', 2684)) + + def test_a_change_with_no_pr_gets_no_reference(self): + # A commit pushed straight to the branch has no pull request, and is + # still a change that shipped. It is listed with nothing after it + # rather than with a placeholder: a reader can act on "there is no + # pull request for this", where a question mark only reads as + # something having gone wrong. + assert self.entry(Change('A fix')).endswith('* A fix\n\n') + + def test_the_credit_goes_before_the_reference(self): + # `* by (#)`, which is the shape operator's own + # hand-written entries use: `* Fix typos in code snippets by + # @MattiaSarti (#1750)`. + assert '* A fix by @someone (#2684)' in self.entry(Change('A fix', 2684, '@someone')) + + def test_a_credited_change_with_no_pr_keeps_the_credit(self): + assert self.entry(Change('A fix', None, 'Iya Mg')).endswith('* A fix by Iya Mg\n\n') + + def test_empty_release(self): + empty = {category: [] for category in CATEGORIES} + assert format_changes(empty, '1.2.3', datetime.date(2026, 9, 10)) == ( + '# 1.2.3 - 10 September 2026\n\n' + ) + + def test_the_tag_is_used_verbatim(self): + empty = {category: [] for category in CATEGORIES} + assert format_changes(empty, '3.4.0b1', datetime.date(2026, 9, 10)).startswith( + '# 3.4.0b1 - ' + ) + + +class CommitTypeToCategoryTests(unittest.TestCase): + def test_known_types(self): + assert commit_type_to_category('feat') == 'Features' + assert commit_type_to_category('fix') == 'Fixes' + assert commit_type_to_category('docs') == 'Documentation' + assert commit_type_to_category('test') == 'Tests' + assert commit_type_to_category('ci') == 'CI' + assert commit_type_to_category('perf') == 'Performance' + assert commit_type_to_category('refactor') == 'Refactoring' + assert commit_type_to_category('revert') == 'Reverted' + assert commit_type_to_category('breaking') == 'Breaking Changes' + + def test_unknown_type_is_capitalised(self): + assert commit_type_to_category('whatever') == 'Whatever' + + def test_chore_has_no_category(self): + # It has no heading because it is not a category. That it still + # returns something readable is a property of the fallback, not an + # invitation to render it. + assert 'chore' not in CATEGORY_HEADINGS + assert 'chore' not in CATEGORIES + + def test_every_category_has_a_heading(self): + # Otherwise a category would render under a capitalised version of + # its own key, which is only ever right by accident. + assert set(CATEGORIES) <= set(CATEGORY_HEADINGS) + + +# The `chore` half of the 3.8.2 fixture on its own: eleven real pull requests, +# nothing else. A release with nothing in it but dependency bumps and charm +# pins is not hypothetical, and it is a patch. +OPERATOR_CHORE_ONLY_LOG = git_log( + *(commit for commit in OPERATOR_3_8_2_COMMITS if commit[1].startswith('chore')) +) + +# The one `!` pull request operator has merged into a 3.x release, by itself. +# The rest of the 3.7.1..3.8.0 range is what makes that release obviously a +# minor one; without it, the `!` has to carry the decision alone. +OPERATOR_BREAKING_ONLY_LOG = git_log(( + TONY, + 'refactor!: move the otlp-json package to be a regular ops-tracing module (#2585)', + '', +)) + + +def categories_of(log: str) -> dict[str, list[Change]]: + return parse_git_log(log, team=OPERATOR_TEAM, repo=REPO) + + +class BumpSizeTests(unittest.TestCase): + """The rule -- a `feat` in the range means minor, otherwise patch -- against real releases.""" + + def test_a_release_with_no_features_is_a_patch(self): + # 3.8.1 -> 3.8.2: two fixes, seven docs, three CI, eleven chore. It + # shipped as a patch. + assert infer_bump_size(categories_of(OPERATOR_3_8_2_LOG)) == PATCH + + def test_a_release_with_a_feature_is_a_minor(self): + # 3.7.1 -> 3.8.0, trimmed: one `feat` (#2555) among four pull + # requests. It shipped as a minor. + assert infer_bump_size(categories_of(OPERATOR_BREAKING_LOG)) == MINOR + + def test_a_breaking_change_on_its_own_is_a_minor(self): + # #2585 is a `refactor!`, so on the plain reading of the rule -- "a + # `feat` in the range means minor" -- a release containing only it + # would be a patch, and a breaking change would ship in a patch + # release. A `!` does not infer a major bump, because we have decided + # to let a breaking change ride in a minor when the impact has been + # checked. Riding in a patch is not the same decision, and is not one + # anyone has made. So a `!` means at least minor. + assert infer_bump_size(categories_of(OPERATOR_BREAKING_ONLY_LOG)) == MINOR + + def test_a_breaking_feature_is_still_a_minor(self): + # The regression this guards against: parsing *moves* a `!` entry out + # of its real type, so a range whose only feature is a `feat!` has an + # empty `feat` list. A rule that read `feat` alone would call this a + # patch. operator has not merged a `feat!` into a 3.x release, so this + # commit is made up rather than lifted. + categories = categories_of(git_log((TONY, 'feat!: replace the framework API (#1)', ''))) + assert categories['feat'] == [] + assert infer_bump_size(categories) == MINOR + + def test_a_release_of_nothing_but_chores_is_a_patch(self): + assert infer_bump_size(categories_of(OPERATOR_CHORE_ONLY_LOG)) == PATCH + + def test_an_empty_range_is_a_patch(self): + assert infer_bump_size(categories_of('')) == PATCH + + def test_major_is_never_inferred(self): + # Even with every category populated, including breaking. A major + # release is the explicit version input's job. + categories = {category: [Change(f'A {category} change', 1)] for category in CATEGORIES} + assert infer_bump_size(categories) == MINOR + + def test_the_minor_categories_are_categories(self): + # Otherwise this would be a second type list quietly diverging from + # the first: a renamed category would stop being a minor bump without + # anything saying so. + assert set(MINOR_BUMP_CATEGORIES) <= set(CATEGORIES) + + +class NextVersionTests(unittest.TestCase): + """Applying a size to a version. Generic semver, and nothing beyond it.""" + + def test_real_history(self): + # The two releases the fixtures above are taken from. + assert next_version(previous='3.7.1', size=MINOR) == '3.8.0' + assert next_version(previous='3.8.1', size=PATCH) == '3.8.2' + + def test_a_minor_bump_zeroes_the_patch(self): + assert next_version(previous='3.8.2', size=MINOR) == '3.9.0' + + def test_components_are_numbers_not_digits(self): + assert next_version(previous='3.9.9', size=PATCH) == '3.9.10' + assert next_version(previous='2.23.16', size=PATCH) == '2.23.17' + assert next_version(previous='3.9.1', size=MINOR) == '3.10.0' + + def test_surrounding_whitespace_is_tolerated(self): + # `--previous "$(git describe --tags --abbrev=0)"` arrives with a + # newline on it often enough to be worth not failing over. + assert next_version(previous=' 3.8.1\n', size=PATCH) == '3.8.2' + + def test_a_dev_version_is_rejected(self): + # The one that matters. Between releases `ops/version.py` holds + # something like 3.9.0.dev0, and reaching for it as the previous + # version is the easy mistake: it is a guess made by the last + # post-release bump, not a version that was ever released. Bumping it + # would skip a version, and stripping the suffix silently would + # release whatever that guess happened to be. + with self.assertRaises(ValueError): + next_version(previous='3.9.0.dev0', size=MINOR) + + def test_a_pre_release_is_rejected(self): + for version in ('3.8.0b1', '3.8.0rc1', '3.8.0a1'): + with self.assertRaises(ValueError): + next_version(previous=version, size=MINOR) + + def test_a_tag_that_is_not_a_version_is_rejected(self): + for previous in ('v3.8.1', '3.8', '', 'main'): + with self.assertRaises(ValueError): + next_version(previous=previous, size=PATCH) + + def test_an_unknown_size_is_rejected(self): + # Including 'major', which is not a size this package produces. + with self.assertRaises(ValueError): + next_version(previous='3.8.1', size='major') # type: ignore[arg-type] + + def test_the_error_points_at_the_way_out(self): + with self.assertRaises(ValueError) as raised: + next_version(previous='3.9.0.dev0', size=MINOR) + assert 'explicitly' in str(raised.exception) + + +class GitLogParseTests(unittest.TestCase): + """The primary input: commit subjects, which is what the convention governs.""" + + def parse(self, *commits: tuple[tuple[str, str], str, str]) -> dict[str, list[Change]]: + return parse_git_log(git_log(*commits), team=OPERATOR_TEAM, repo=REPO) + + def test_the_fixture_format_is_the_format_git_is_asked_for(self): + # `git_log` writes the separators by hand, so if the package ever + # changed what it asks `git log` for, these fixtures would go on + # passing while every real caller broke. The format string is the + # contract; this is the only place it is checked against the fixtures. + assert GIT_LOG_FORMAT == '%x1e%an%x1f%ae%x1f%s%x1f%b' + + def test_the_real_release(self): + categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) + assert categories['fix'] == [ + Change('Compare full event paths when skipping duplicate notices', 2684, '@Ali-932'), + Change("Don't pass a message when converting an unknown status by name", 2700), + ] + assert categories['ci'] == [ + Change('Point DB charm CI at the moved mysql-operators repo', 2551), + Change('Switch example charm integration tests to Concierge `k8s` preset', 2696), + Change('Use the upstream concierge presets again', 2699), + ] + + def test_the_pr_number_comes_off_the_subject(self): + # The `(#N)` a squash merge appends, and the only thing the git log + # says about the pull request. Over operator's last 300 commits every + # subject has one. + categories = self.parse((TONY, 'fix: do the thing (#1234)', '')) + assert categories['fix'] == [Change('Do the thing', 1234)] + + def test_a_commit_with_no_pr_number_carries_none(self): + # A commit pushed straight to the branch. operator has these, from + # before the squash-merge policy -- `chore: remove odd argument to + # "raise NotImplementedError" in harness.py` is one, by Ben, with no + # suffix. The change is real, so it is carried with nothing in the + # number rather than with a placeholder that reads like a bug. + categories = self.parse((TONY, 'fix: do the thing', '')) + assert categories['fix'] == [Change('Do the thing', None)] + + def test_a_number_that_is_not_the_suffix_is_not_the_pr(self): + # Only a trailing `(#N)` counts, so a summary that happens to mention + # an issue does not get mistaken for one. + categories = self.parse((TONY, 'fix: handle (#5) style input properly (#1234)', '')) + assert categories['fix'] == [Change('Handle (#5) style input properly', 1234)] + + def test_chore_is_dropped_here_too(self): + categories = self.parse((TONY, 'chore: bump something (#1)', '')) + assert all(not items for items in categories.values()) + + def test_a_breaking_commit_moves_to_breaking_with_its_type_kept(self): + categories = self.parse((TONY, 'refactor!: move the thing (#2585)', '')) + assert categories['breaking'] == [Change('Refactor: Move the thing', 2585)] + assert categories['refactor'] == [] + + def test_a_scope_is_accepted_and_dropped(self): + # `canonical/pebble` scopes most of its dependency bumps and a good + # deal else, so this is a real shape and not a hypothetical one. The + # scope does not reach the entry: see `COMMIT_SUBJECT_REGEX`. + categories = self.parse((TONY, 'fix(reaper): do the thing (#1)', '')) + assert categories['fix'] == [Change('Do the thing', 1)] + + def test_a_comma_separated_scope_is_one_scope(self): + # `fix(cmdstate,wsutil):` is a real pebble subject, and the shape most + # likely to be read as two groups by a regex written for one. + categories = self.parse((TONY, 'fix(cmdstate,wsutil): do the thing (#1)', '')) + assert categories['fix'] == [Change('Do the thing', 1)] + + def test_a_breaking_scoped_commit_is_still_breaking(self): + categories = self.parse((TONY, 'feat(api)!: replace it (#1)', '')) + assert categories['breaking'] == [Change('Feat: Replace it', 1)] + + def test_a_subject_that_is_not_conventional_is_dropped(self): + # A merge commit, or anything from before the convention. `--no-merges` + # is the recommendation rather than the requirement because of this. + categories = self.parse( + (TONY, "Merge remote-tracking branch 'source/main' into import-ops-scenario", ''), + (TONY, 'Reorganise the scenario files.', ''), + (TONY, 'fix: a real one (#1)', ''), + ) + assert categories['fix'] == [Change('A real one', 1)] + assert sum(len(items) for items in categories.values()) == 1 + + def test_a_body_does_not_end_a_record(self): + # The reason the format uses control characters: a commit body is + # arbitrary text with blank lines in it, so anything line-oriented + # would lose track of where the next commit starts. + categories = self.parse( + (TONY, 'fix: the first (#1)', 'A body.\n\nWith a blank line.\n\n* And a bullet.\n'), + (TONY, 'fix: the second (#2)', ''), + ) + assert categories['fix'] == [Change('The first', 1), Change('The second', 2)] + + def test_an_empty_log_is_every_category_empty(self): + assert parse_git_log('') == {category: [] for category in CATEGORIES} + + def test_trailing_whitespace_between_records_is_tolerated(self): + # `git log` output arrives with a newline on the end of it. + categories = parse_git_log( + git_log((TONY, 'fix: do the thing (#1)', '')) + '\n', team=OPERATOR_TEAM + ) + assert categories['fix'] == [Change('Do the thing', 1)] + + +class AuthorCreditTests(unittest.TestCase): + """Who gets named in a bullet. All three contributors here are observed.""" + + def parse(self, author: tuple[str, str], team=OPERATOR_TEAM) -> list[Change]: + return parse_git_log(git_log((author, 'fix: do the thing (#1)', '')), team=team)['fix'] + + def test_a_team_member_is_not_credited(self): + # By email, which is all a git log gives for someone who commits + # under a real address. + assert self.parse(TONY) == [Change('Do the thing', 1)] + + def test_an_outside_contributor_is_credited_by_handle(self): + # `46688206+Ali-932@users.noreply.github.com` -> `@Ali-932`. This is + # GitHub's default commit address for an account with a private + # email, so it is the usual case for a drive-by contributor. + assert self.parse(ALI) == [Change('Do the thing', 1, '@Ali-932')] + + def test_the_older_no_reply_form_works_too(self): + assert self.parse(('Someone', 'someone@users.noreply.github.com')) == [ + Change('Do the thing', 1, '@someone') + ] + + def test_an_outside_contributor_with_no_handle_is_credited_by_name(self): + # Iya Mg's #2540 is a real documentation fix in 3.8.0, committed from + # a personal address. There is no handle to be had, and the two + # alternatives -- dropping them, or rendering an `@` in front of + # something that is not a handle -- are both worse than the name. + assert self.parse(IYA) == [Change('Do the thing', 1, 'Iya Mg')] + + def test_another_canonical_team_is_still_outside_this_one(self): + # "External to the Charm Tech team" is not "external to Canonical". + # A contributor from another team has an @canonical.com address, no + # derivable handle, and every bit as much claim to the credit. + assert self.parse(('Some One', 'some.one@canonical.com')) == [ + Change('Do the thing', 1, 'Some One') + ] + + def test_a_team_member_is_matched_by_handle_as_well_as_by_email(self): + # Someone on the team who commits from a no-reply address is matched + # on the handle the email yields, so a team list of handles alone + # still works. + assert self.parse( + ('Tony Meyer', '12345+tonyandrewmeyer@users.noreply.github.com'), + team=('@tonyandrewmeyer',), + ) == [Change('Do the thing', 1)] + + def test_matching_ignores_case_and_a_leading_at(self): + for member in ('TONY.MEYER@CANONICAL.COM', 'tony.meyer@canonical.com'): + assert self.parse(TONY, team=(member,)) == [Change('Do the thing', 1)], member + no_reply = ('Tony Meyer', '12345+tonyandrewmeyer@users.noreply.github.com') + for member in ('@TONYANDREWMEYER', 'tonyandrewmeyer', '@tonyandrewmeyer'): + assert self.parse(no_reply, team=(member,)) == [Change('Do the thing', 1)], member + + def test_a_handle_in_the_team_does_not_match_an_author_who_has_no_handle(self): + # Not a bug, and worth pinning: nothing in a git log connects + # `tony.meyer@canonical.com` to `@tonyandrewmeyer`. A team list of + # handles alone covers only the members who commit from a GitHub + # no-reply address, which is why `OPERATOR_TEAM` carries both. + assert self.parse(TONY, team=('@tonyandrewmeyer',)) == [ + Change('Do the thing', 1, 'Tony Meyer') + ] + + def test_an_empty_team_credits_everyone(self): + # The safe failure. A repository that has not said who maintains it + # over-credits, which is visible in the draft release and takes one + # edit; the other way round, a contributor is silently left out. + assert self.parse(TONY, team=()) == [Change('Do the thing', 1, 'Tony Meyer')] + + +class RevertTests(unittest.TestCase): + """Reverts. + + Working out whether a revert cancels something needs the revert commit's + *body*, which is why the parser reads whole log records rather than + subjects alone. + + Only the first case here is observed. operator has merged exactly one + revert into a 3.x release -- #2568, reverting #2538 -- and it reverts a + `chore`, which the changelog drops anyway, so the cancelling is real but + the cancellation is not what makes it invisible. Every other fixture in + this class is constructed: there is no instance in recent history of a + revert of something in the changelog, and none at all of a revert of a + released feature. + """ + + def parse(self, *commits: tuple[tuple[str, str], str, str]) -> dict[str, list[Change]]: + return parse_git_log(git_log(*commits), team=OPERATOR_TEAM, repo=REPO) + + def test_the_real_revert_range(self): + # Observed: six commits from 3.7.1..3.8.0. #2538 and #2568 cancel, + # and neither is in the output -- though both are `chore`, so both + # would have been dropped regardless. + categories = self.parse(*OPERATOR_REVERT_RANGE_COMMITS) + assert categories['revert'] == [] + assert categories['fix'] == [ + Change('Treat remote unit zero as explicit', 2454, '@gcomneno') + ] + assert categories['docs'] == [ + Change('Fix small issues in K8s tutorial', 2540, 'Iya Mg'), + Change('Replace CoC with link to Ubuntu Code of Conduct', 2564), + Change('Add guidance about names of workload-less charms', 2496, 'deusebio'), + ] + + def test_a_revert_within_the_range_cancels_both_halves(self): + # Constructed: a revert of a `fix` in the same range, which has no + # instance in operator's recent history. A change that landed and was + # taken out again before anything shipped did not happen as far as a + # reader is concerned, so listing either half would be describing + # something that never reached anyone. + categories = self.parse( + (TONY, 'fix: do the thing (#100)', ''), + (TONY, 'fix: do the other thing (#101)', ''), + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts canonical/operator#100\n'), + ) + assert categories['fix'] == [Change('Do the other thing', 101)] + assert categories['revert'] == [] + assert categories['breaking'] == [] + + def test_a_revert_of_something_released_is_called_out(self): + # Constructed. #99 is not in this range, so it shipped: the reader + # needs to be told it has been taken back out. It keeps its own + # `Reverted` heading rather than being filed under the `fix` it + # undoes, which would read as a new fix rather than as a removal. + categories = self.parse( + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['revert'] == [Change('"fix: do the thing"', 102)] + assert categories['fix'] == [] + + def test_a_revert_of_something_released_is_at_least_a_patch(self): + categories = self.parse( + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert infer_bump_size(categories) == PATCH + + def test_a_revert_of_a_released_feature_is_breaking(self): + # Constructed, and the decision worth arguing with: taking away a + # feature people may already be building on is a removal of + # behaviour, whatever the commit type on the revert says. So it goes + # to `breaking`, which is both the loudest heading and -- via + # `MINOR_BUMP_CATEGORIES` -- a minor bump rather than a patch. + categories = self.parse( + (TONY, 'revert: "feat: add the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['breaking'] == [Change('Revert: "feat: add the thing"', 102)] + assert categories['revert'] == [] + assert infer_bump_size(categories) == MINOR + + def test_a_revert_of_a_released_breaking_change_is_breaking(self): + categories = self.parse( + (TONY, 'revert: "refactor!: move it" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['breaking'] == [Change('Revert: "refactor!: move it"', 102)] + + def test_a_revert_of_a_released_feature_in_range_still_cancels(self): + # The cancelling comes first: a feature that never shipped cannot be + # a breaking removal of anything. + categories = self.parse( + (TONY, 'feat: add the thing (#99)', ''), + (TONY, 'revert: "feat: add the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['breaking'] == [] + assert categories['feat'] == [] + assert infer_bump_size(categories) == PATCH + + def test_an_unquoted_reverts_line_is_read(self): + # GitHub's Revert button writes `Reverts owner/repo#N`, but a + # hand-written revert often leaves the owner/repo off. + categories = self.parse( + (TONY, 'fix: do the thing (#100)', ''), + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts #100\n'), + ) + assert categories['fix'] == [] + assert categories['revert'] == [] + + def test_a_reverts_line_naming_another_repository_is_ignored(self): + # Otherwise `Reverts some/other#100` would cancel this repository's + # #100, which has nothing to do with it. + categories = self.parse( + (TONY, 'fix: do the thing (#100)', ''), + (TONY, 'revert: "fix: something else" (#102)', 'Reverts some/other#100\n'), + ) + assert categories['fix'] == [Change('Do the thing', 100)] + assert categories['revert'] == [Change('"fix: something else"', 102)] + + def test_a_revert_with_no_reverts_line_is_called_out(self): + # Nothing says what it undoes, so there is nothing to cancel it + # against. Showing it is the safe answer: the alternative is hiding a + # change that shipped. + categories = self.parse((TONY, 'revert: "fix: do the thing" (#102)', 'No idea.\n')) + assert categories['revert'] == [Change('"fix: do the thing"', 102)] + + def test_a_revert_of_a_chore_is_still_dropped_when_called_out(self): + # `revert` is a category and `chore` is not, so a revert of a chore + # that is *not* cancelled still appears. That is the right way round: + # the type of the thing reverted does not decide whether a revert is + # worth reporting, because the reader is being told about the removal + # rather than about the original. + categories = self.parse( + (TONY, 'revert: "chore: bump it" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['revert'] == [Change('"chore: bump it"', 102)] + + +class ConsoleScriptTests(unittest.TestCase): + """The `changelog` console script: a range on stdin, one answer on stdout.""" + + def run_cli(self, *argv: str, stdin: str = OPERATOR_3_8_2_LOG) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with ( + mock.patch('sys.stdin', io.StringIO(stdin)), + contextlib.redirect_stdout(out), + contextlib.redirect_stderr(err), + ): + returncode = _cli.main(argv) + return returncode, out.getvalue(), err.getvalue() + + def test_bump_size_prints_one_bare_word(self): + # `SIZE=$(changelog bump-size < log.txt)` is the whole of the + # plumbing, so anything else on stdout -- a label, a prefix, JSON -- + # would have to be stripped back off in the workflow. + assert self.run_cli('bump-size') == (0, 'patch\n', '') + assert self.run_cli('bump-size', stdin=OPERATOR_BREAKING_LOG) == (0, 'minor\n', '') + + def test_next_version_prints_one_bare_word(self): + assert self.run_cli('next-version', '--previous', '3.8.1') == (0, '3.8.2\n', '') + minor = self.run_cli('next-version', '--previous', '3.7.1', stdin=OPERATOR_BREAKING_LOG) + assert minor == (0, '3.8.0\n', '') + + def test_next_version_fails_rather_than_guessing(self): + returncode, out, err = self.run_cli('next-version', '--previous', '3.9.0.dev0') + assert returncode == 2 + # Nothing on stdout: a workflow capturing this into a variable gets + # an empty one and a non-zero step, not a plausible wrong version. + assert out == '' + assert '3.9.0.dev0' in err + + def test_release_notes_is_the_library_output(self): + categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) + _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--team', TEAM_ARGUMENT) + # No newline is added here, because the library's last line is the + # blank one after the final category. With a compare link at the end + # there is no trailing blank line, and `_emit` adds one; the + # release-notes-input case below is that one. + assert out == format_release_notes(categories, None, repo=REPO) + assert out.endswith('/pull/2699\n') + + def test_release_notes_needs_a_repo_to_build_links_from(self): + # A change carries a number, not a URL, so there is nothing to render + # a link out of without this. Failing is better than printing a body + # whose every bullet has quietly lost its link. + with self.assertRaises(SystemExit): + self.run_cli('release-notes') + + def test_release_notes_takes_a_compare_link_it_cannot_work_out(self): + # A git log has no equivalent of the line GitHub's generated notes + # end with, and the tags at either end of the range are the caller's + # to know, so this is how a workflow keeps the link. + url = 'https://github.com/canonical/operator/compare/3.8.1...3.8.2' + _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--compare-url', url) + assert out.endswith(f'**Full Changelog**: {url}\n') + + def test_release_notes_has_no_compare_link_by_default(self): + _, out, _ = self.run_cli('release-notes', '--repo', REPO) + assert 'Full Changelog' not in out + + def test_changes_entry_is_the_library_output_byte_for_byte(self): + categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM) + _, out, _ = self.run_cli( + 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31', '--team', TEAM_ARGUMENT + ) + # Including the blank line it ends with: this text is prepended to + # CHANGES.md verbatim, so the trailing layout is part of the answer. + assert out == format_changes(categories, '3.8.2', datetime.date(2026, 8, 31)) + assert out.endswith('(#2699)\n\n') + + def test_changes_entry_needs_no_repo(self): + # It renders `(#2684)` and never a URL, so unlike `release-notes` it + # has nothing to build out of a repository name. + returncode, out, _ = self.run_cli( + 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31' + ) + assert returncode == 0 + assert '(#2684)' in out + + def test_the_team_is_comma_separated_and_repeatable(self): + # A workflow passes one repository variable with commas in it; a + # person types one `--team` per person. Both, and a mixture. + # A handle rather than an email, because it has to suppress the + # credit on both input paths and only the git log has an email to + # match: see `test_an_email_cannot_match_an_author_the_notes_name`. + ali = '@Ali-932' + for argv in ( + ('--team', f'{TEAM_ARGUMENT},{ali}'), + ('--team', TEAM_ARGUMENT, '--team', ali), + ('--team', TEAM_ARGUMENT, '--team', f'someone@example.com,{ali}'), + ): + _, out, _ = self.run_cli( + 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31', *argv + ) + # Everyone in this range is now accounted for, so the one + # bullet that was credited no longer is. + uncredited = '* Compare full event paths when skipping duplicate notices (#2684)' + assert uncredited in out, argv + assert '@Ali-932' not in out, argv + + def test_without_a_team_everyone_is_credited(self): + # The safe way round: over-crediting shows up in the draft release + # and takes one edit, while crediting nobody is invisible. Note that + # Tony is credited by *name* here: he commits from an @canonical.com + # address, so the git log has no handle for him. + _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2', '--date', '2026-08-31') + assert ( + '* Compare full event paths when skipping duplicate notices by @Ali-932 (#2684)' in out + ) + assert '* Stop styling page references as blockquotes by Tony Meyer (#2666)' in out + + def test_changes_entry_defaults_to_today(self): + with mock.patch.object(_cli, '_today', return_value=datetime.date(2026, 9, 11)): + _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2') + assert out.startswith('# 3.8.2 - 11 September 2026\n') + + def test_git_log_format_prints_the_format_and_reads_nothing(self): + # The one subcommand that does not touch stdin. It exists so that the + # separators live in one place: a workflow that retypes them and drops + # one gets an empty changelog rather than an error. + assert self.run_cli('git-log-format', stdin='') == (0, GIT_LOG_FORMAT + '\n', '') + + def test_the_clock_is_read_here_and_only_here(self): + # The `no_clock` fixture is active for this test, as it is for every + # other, and `_cli` is deliberately outside it. If this starts + # failing, the boundary has moved: something in the library is + # reading the clock, or `_today` has been moved in with it. + assert isinstance(_cli._today(), datetime.date) + + def test_an_unparseable_date_is_rejected(self): + with self.assertRaises(SystemExit): + self.run_cli('changes-entry', '--tag', '3.8.2', '--date', 'yesterday') + + def test_a_subcommand_is_required(self): + with self.assertRaises(SystemExit): + self.run_cli() + + def test_the_entry_point_names_something_that_exists(self): + # A renamed `main` breaks the console script without breaking a + # single test that calls `_cli.main` directly, so pin the string in + # pyproject.toml against the module. + pyproject = (pathlib.Path(__file__).parent.parent / 'pyproject.toml').read_text() + assert 'changelog = "charm_tech_code.changelog._cli:main"' in pyproject + assert callable(_cli.main) diff --git a/changelog/uv.lock b/changelog/uv.lock new file mode 100644 index 0000000..b6f3feb --- /dev/null +++ b/changelog/uv.lock @@ -0,0 +1,156 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "charm-tech-code-changelog" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +unit = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +unit = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/pyproject.toml b/pyproject.toml index 8a87244..ca1286d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ preview = true [tool.ruff.format] quote-style = "single" +# The rule set the team agreed on: canonical/charm-tech `style/python.md`, +# "Tooling configuration". It lives here so that every tool in the monorepo +# gets it, the same way the line length and the quote style do. [tool.ruff.lint] select = [ # Pyflakes @@ -49,8 +52,46 @@ select = [ "N", # flake8-builtins "A", - # flake8-bugbear - "B", + # flake8-copyright + "CPY", # pyupgrade "UP", + # flake8-2020 + "YTT", + # flake8-bandit + "S", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # ruff-specific + "RUF", + # perflint + "PERF", + # pydocstyle + "D", + # flake8-future-annotations + "FA", + # flake8-type-checking + "TC", +] +ignore = [ + # Imports used only in annotations do not have to move into a + # `TYPE_CHECKING` block. + "TC001", + "TC002", + "TC003", + # `assert` is fine. + "S101", + # Magic methods and `__init__` do not need docstrings. + "D105", + "D107", + # Two pairs that selecting all of `D` turns on together and that + # contradict each other. Ruff resolves both on its own but warns on every + # run until one of each pair is named here. + "D203", + "D213", ] + +[tool.ruff.lint.per-file-ignores] +"*/tests/*" = ["D", "S101", "S105", "S106"]