Skip to content

Commit 45b7a3e

Browse files
committed
v2.4.0: init scaffolding, dep --requirements, mkdocs plugin
1 parent 88fb0f6 commit 45b7a3e

10 files changed

Lines changed: 577 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
Parse, query, and validate changelogs in Python and CI.
44

5+
## [2.4.0] - 2026-07-19
6+
7+
### Added
8+
9+
- `patchnotes init` scaffolds a spec-compliant starter CHANGELOG.md (strict-validation clean out of the box); `--workflow` also writes a ready-made `.github/workflows/changelog.yml` PR check.
10+
- `dep --requirements old.txt new.txt` diffs two requirements files and runs the breaking/security analysis for every changed pin at once — built for reviewing lockfile bump PRs. With `--strict`, flagged changes fail CI.
11+
- mkdocs plugin: add `patchnotes` to `plugins:` in mkdocs.yml and a `<!-- patchnotes -->` marker in any docs page renders the styled changelog at build time (`pip install patchnotes[mkdocs]`).
12+
13+
### Fixed
14+
15+
- An empty [Unreleased] section no longer triggers a PN203 warning — it's the normal state right after a release (and what `bump` leaves behind). Empty *versioned* releases still warn.
16+
517
## [2.3.0] - 2026-07-19
618

719
### Added

‎README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ pip install patchnotes
3434

3535
Requires Python 3.10+.
3636

37+
Starting a new project? `patchnotes init` creates a spec-compliant starter
38+
changelog, and `patchnotes init --workflow` adds a ready-made PR validation
39+
workflow too.
40+
3741
---
3842

3943
## Usage
@@ -268,6 +272,14 @@ changelog, and flags breaking/removed/security/deprecated entries in the
268272
version range. `--all` shows everything; `--format json` for scripting.
269273
Best-effort: needs the dependency to keep a parseable changelog.
270274

275+
Reviewing a whole lockfile bump? Diff two requirements files at once:
276+
277+
```bash
278+
patchnotes dep --requirements old-requirements.txt requirements.txt
279+
# analyzes every changed pin, lists added/removed packages,
280+
# and with --strict exits 1 if anything breaking/security is flagged
281+
```
282+
271283
---
272284

273285
## Rendering
@@ -482,6 +494,22 @@ This repository dogfoods all of it: [`ci.yml`](.github/workflows/ci.yml) validat
482494

483495
---
484496

497+
## mkdocs
498+
499+
Render the changelog into your documentation site (`pip install patchnotes[mkdocs]`):
500+
501+
```yaml
502+
# mkdocs.yml
503+
plugins:
504+
- patchnotes:
505+
file: CHANGELOG.md
506+
```
507+
508+
Then put `<!-- patchnotes -->` in any docs page — it's replaced with the
509+
parsed, styled changelog at build time.
510+
511+
---
512+
485513
## pre-commit
486514

487515
Validate (or auto-fix) the changelog on every commit:

‎patchnotes/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
register_format,
6464
)
6565

66-
__version__ = "2.3.0"
66+
__version__ = "2.4.0"
6767
__all__ = [
6868
"parse",
6969
"parse_file",

‎patchnotes/_cli.py‎

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@
4444
"fix": (0, 0),
4545
"check-version": (0, 0),
4646
"fragment": (1, 3),
47-
"dep": (3, 3),
47+
"dep": (1, 3),
4848
"badge": (0, 0),
49+
"init": (0, 0),
4950
}
5051

5152
#: commands that accept an optional trailing file argument
@@ -153,6 +154,17 @@ def main(argv=None) -> int:
153154
help="Fragments directory (default: changelog.d/ next to the "
154155
"changelog)",
155156
)
157+
parser.add_argument(
158+
"--requirements",
159+
action="store_true",
160+
help="With 'dep': compare two requirements files instead of one "
161+
"package (dep --requirements old.txt new.txt)",
162+
)
163+
parser.add_argument(
164+
"--workflow",
165+
action="store_true",
166+
help="With 'init': also scaffold .github/workflows/changelog.yml",
167+
)
156168
parser.add_argument(
157169
"--all",
158170
action="store_true",
@@ -204,6 +216,8 @@ def main(argv=None) -> int:
204216
if args.format == "sarif" and args.command != "validate":
205217
parser.error("--format sarif is only supported with 'validate'")
206218

219+
if args.command == "init":
220+
return _cmd_init(args)
207221
if args.command == "dep":
208222
return _cmd_dep(args)
209223
if args.command == "fragment":
@@ -590,7 +604,40 @@ def _cmd_fragment(args) -> int:
590604
return EXIT_USAGE
591605

592606

607+
def _cmd_init(args) -> int:
608+
from ._scaffold import write_changelog, write_workflow
609+
610+
try:
611+
write_changelog(args.file, repo_url=args.repo_url)
612+
except FileExistsError as e:
613+
print(f"Error: {e}", file=sys.stderr)
614+
return EXIT_FAIL
615+
created = [args.file]
616+
if args.workflow:
617+
try:
618+
created.append(write_workflow(args.file))
619+
except FileExistsError as e:
620+
print(f"Warning: {e}", file=sys.stderr)
621+
if not args.quiet:
622+
for path in created:
623+
print(f"Created {path}")
624+
print("Next: add entries under [Unreleased], then "
625+
f"`patchnotes {args.file} bump X.Y.Z` on release day.")
626+
return EXIT_OK
627+
628+
593629
def _cmd_dep(args) -> int:
630+
if args.requirements:
631+
if len(args.params) != 2:
632+
print("Usage: patchnotes dep --requirements OLD.txt NEW.txt",
633+
file=sys.stderr)
634+
return EXIT_USAGE
635+
return _cmd_dep_requirements(args)
636+
if len(args.params) != 3:
637+
print("Usage: patchnotes dep PACKAGE FROM_VERSION TO_VERSION",
638+
file=sys.stderr)
639+
return EXIT_USAGE
640+
594641
from ._depdiff import fetch_dep_changelog, find_github_repo, render_dep_diff
595642

596643
package, from_v, to_v = args.params
@@ -622,6 +669,80 @@ def _cmd_dep(args) -> int:
622669
))
623670
else:
624671
print(text)
672+
if args.strict and flagged:
673+
return EXIT_FAIL
674+
return EXIT_OK
675+
676+
677+
def _cmd_dep_requirements(args) -> int:
678+
from ._depdiff import (
679+
diff_requirements,
680+
fetch_dep_changelog,
681+
find_github_repo,
682+
render_dep_diff,
683+
)
684+
685+
old_path, new_path = args.params
686+
try:
687+
with open(old_path, "r", encoding="utf-8") as fh:
688+
old_text = fh.read()
689+
with open(new_path, "r", encoding="utf-8") as fh:
690+
new_text = fh.read()
691+
except OSError as e:
692+
print(f"Error: {e}", file=sys.stderr)
693+
return EXIT_USAGE
694+
695+
changed, added, removed = diff_requirements(old_text, new_text)
696+
total_flagged = 0
697+
results = []
698+
699+
for name, old_v, new_v in changed:
700+
try:
701+
owner, repo = find_github_repo(name)
702+
cl = fetch_dep_changelog(owner, repo)
703+
text, flagged = render_dep_diff(
704+
cl, name, old_v, new_v, show_all=args.show_all
705+
)
706+
results.append({"package": name, "from": old_v, "to": new_v,
707+
"flagged": flagged, "text": text, "error": None})
708+
total_flagged += flagged
709+
except (ValueError, OSError) as e:
710+
results.append({"package": name, "from": old_v, "to": new_v,
711+
"flagged": 0, "text": None, "error": str(e)})
712+
713+
if args.format == "json":
714+
print(json.dumps(
715+
{
716+
"changed": [
717+
{k: v for k, v in r.items() if k != "text"}
718+
for r in results
719+
],
720+
"added": added,
721+
"removed": removed,
722+
"flagged": total_flagged,
723+
},
724+
indent=2,
725+
))
726+
return EXIT_FAIL if (args.strict and total_flagged) else EXIT_OK
727+
728+
if not changed and not added and not removed:
729+
print("No pinned dependency changes found.")
730+
return EXIT_OK
731+
for r in results:
732+
print()
733+
if r["error"]:
734+
print(f"{r['package']}: {r['from']} -> {r['to']}")
735+
print(f" (couldn't analyze: {r['error']})")
736+
else:
737+
print(r["text"])
738+
if added:
739+
print(f"\nNew dependencies: {', '.join(added)}")
740+
if removed:
741+
print(f"Removed dependencies: {', '.join(removed)}")
742+
print(f"\nTotal: {len(changed)} version change(s), "
743+
f"{total_flagged} breaking/security change(s) flagged.")
744+
if args.strict and total_flagged:
745+
return EXIT_FAIL
625746
return EXIT_OK
626747

627748

‎patchnotes/_depdiff.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,42 @@ def render_dep_diff(
139139
else:
140140
lines.append(" No breaking or security changes flagged.")
141141
return "\n".join(lines), flagged
142+
143+
144+
# ── Requirements-file diffing ─────────────────────────────────────────────────
145+
146+
_REQ_LINE = re.compile(
147+
r'^\s*(?P<name>[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)'
148+
r'(?:\[[^\]]*\])?\s*==\s*(?P<version>[^\s;#]+)'
149+
)
150+
151+
152+
def parse_requirements(text: str) -> dict:
153+
"""Extract ``name==version`` pins from a requirements file.
154+
155+
Unpinned lines (>=, ~=, bare names, -r includes, URLs) are ignored —
156+
version diffing only makes sense for exact pins.
157+
"""
158+
pins: dict[str, str] = {}
159+
for line in text.splitlines():
160+
m = _REQ_LINE.match(line)
161+
if m:
162+
pins[m.group("name").lower()] = m.group("version")
163+
return pins
164+
165+
166+
def diff_requirements(old_text: str, new_text: str) -> tuple:
167+
"""Compare two requirements files.
168+
169+
Returns (changed, added, removed) where changed is a list of
170+
(name, old_version, new_version).
171+
"""
172+
old, new = parse_requirements(old_text), parse_requirements(new_text)
173+
changed = [
174+
(name, old[name], new[name])
175+
for name in sorted(old.keys() & new.keys())
176+
if old[name] != new[name]
177+
]
178+
added = sorted(new.keys() - old.keys())
179+
removed = sorted(old.keys() - new.keys())
180+
return changed, added, removed

‎patchnotes/_models.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,9 @@ def _semantic_issues(self) -> list[ValidationIssue]:
226226
)
227227
seen.add(r.version)
228228

229-
if not r.entries:
229+
# An empty [Unreleased] section is normal (e.g. right after a
230+
# release); only warn about empty *versioned* releases.
231+
if not r.entries and not r.is_unreleased:
230232
issues.append(
231233
ValidationIssue(
232234
_v.EMPTY_RELEASE,

‎patchnotes/_scaffold.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""
2+
patchnotes._scaffold
3+
`patchnotes init` — starter changelog and CI workflow for new projects.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import os
9+
from typing import Optional
10+
11+
STARTER_CHANGELOG = """\
12+
# Changelog
13+
14+
All notable changes to this project are documented in this file. The format
15+
follows [Keep a Changelog](https://keepachangelog.com) and this project
16+
adheres to [Semantic Versioning](https://semver.org).
17+
18+
## [Unreleased]
19+
20+
### Added
21+
22+
- Started tracking changes in this changelog
23+
"""
24+
25+
STARTER_WORKFLOW = """\
26+
# Validates the changelog on every PR that touches it, with inline
27+
# annotations on offending lines. Generated by `patchnotes init --workflow`.
28+
name: Validate changelog
29+
30+
on:
31+
pull_request:
32+
paths: ["{file}"]
33+
34+
jobs:
35+
validate:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@v4
39+
- uses: Londopy/patchnotes@v2
40+
with:
41+
file: {file}
42+
strict: "true"
43+
"""
44+
45+
46+
def starter_changelog(repo_url: Optional[str] = None) -> str:
47+
"""The starter CHANGELOG.md content (strict-validation clean)."""
48+
text = STARTER_CHANGELOG
49+
if repo_url:
50+
text += f"\n[unreleased]: {repo_url.rstrip('/')}/commits\n"
51+
return text
52+
53+
54+
def write_changelog(path: str, repo_url: Optional[str] = None) -> None:
55+
"""Create a starter changelog; refuses to overwrite."""
56+
if os.path.exists(path):
57+
raise FileExistsError(
58+
f"{path} already exists — refusing to overwrite. "
59+
f"Run `patchnotes {path} validate` to check it instead."
60+
)
61+
with open(path, "w", encoding="utf-8") as fh:
62+
fh.write(starter_changelog(repo_url))
63+
64+
65+
def write_workflow(changelog_path: str) -> str:
66+
"""Create .github/workflows/changelog.yml next to the repo root."""
67+
base = os.path.dirname(changelog_path) or "."
68+
wf_dir = os.path.join(base, ".github", "workflows")
69+
wf_path = os.path.join(wf_dir, "changelog.yml")
70+
if os.path.exists(wf_path):
71+
raise FileExistsError(f"{wf_path} already exists — refusing to overwrite.")
72+
os.makedirs(wf_dir, exist_ok=True)
73+
rel = os.path.basename(changelog_path)
74+
with open(wf_path, "w", encoding="utf-8") as fh:
75+
fh.write(STARTER_WORKFLOW.format(file=rel))
76+
return wf_path

0 commit comments

Comments
 (0)