Skip to main content

patchnotes

Parse Keep a Changelog formatted CHANGELOG.md files — and YAML changelogs — into structured Python objects. Query, diff, validate, and render to HTML, RSS, or plain text. Built for use in Python code, shell scripts, and CI/CD.

Zero required dependencies. Pure Python. Typed.

import patchnotes

cl = patchnotes.parse_file("CHANGELOG.md")

cl.latest()        # Release(v2.1.0, 2024-11-15, 6 entries)
cl.unreleased()    # Release(vUnreleased, unreleased, 2 entries)
cl.validate()      # [] — or a list of issues with line numbers

# What broke between 1.4.0 and 2.1.0?
for r in cl.diff("1.4.0", "2.1.0"):
    for entry in r.breaking_changes:
        print(f"v{r.version}: {entry.text}")

Install

pip install patchnotes          # core (zero dependencies)
pip install patchnotes[yaml]    # + YAML changelog support

Requires Python 3.10+.


Usage

Parse

import patchnotes

# From a file (format auto-detected from extension/content)
cl = patchnotes.parse_file("CHANGELOG.md")
cl = patchnotes.parse_file("changelog.yml")     # needs patchnotes[yaml]

# From a string
cl = patchnotes.parse(raw_text)
cl = patchnotes.parse(raw_yaml, format="yaml")

# From any URL
cl = patchnotes.Changelog.from_url(
    "https://raw.githubusercontent.com/user/repo/main/CHANGELOG.md"
)

# From a GitHub repo — just owner + repo name, no URL needed
cl = patchnotes.Changelog.from_github("Londopy", "patchnotes")

# Different branch or filename
cl = patchnotes.Changelog.from_github(
    "psf", "requests",
    branch="main",
    filename="HISTORY.md"   # also works with CHANGES.md, NEWS.md, etc.
)

from_github automatically falls back to the master branch if main returns a 404.


Validation and strict mode

The parser is lenient by default: off-standard input (a 2024/01/01 date, a ## 1.2.0 header without brackets, a ### Improvements section) is recovered with the most sensible interpretation and recorded as an issue instead of crashing or silently misparsing.

cl = patchnotes.parse_file("CHANGELOG.md")

for issue in cl.validate():
    print(issue)
    # [ERROR] PN101 line 12: date '2024/01/01' is not ISO 8601 ...
    # [WARNING] PN201 line 30: non-standard section 'Improvements' ...

cl.is_valid()              # True if no ERROR-severity issues
cl.is_valid(strict=True)   # True only if there are zero issues

Strict mode raises instead — useful when a malformed changelog should stop the pipeline:

from patchnotes import ChangelogValidationError

try:
    cl = patchnotes.parse_file("CHANGELOG.md", strict=True)
except ChangelogValidationError as e:
    for issue in e.issues:
        print(issue)
    raise

Issue codes are stable (grep-able in CI logs): PN1xx are errors (data was lost or guessed — bad dates, duplicate versions, malformed headers), PN2xx are warnings (recoverable style problems — unknown section names, out-of-order or empty releases), PN3xx are YAML schema problems.


Formats

Formats are pluggable. markdown (Keep a Changelog) and yaml are built in; format="auto" picks by file extension, then content.

YAML changelog schema:

title: My Project
description: What the project does.
releases:
  - version: "2.0.0"
    date: 2024-06-01
    changes:
      breaking:
        - Renamed foo() to bar()
      added:
        - New thing
  - unreleased: true
    changes:
      fixed:
        - Pending fix

Adding your own format (no core changes needed):

from patchnotes import Changelog, FormatParser, register_format

class MyFormat(FormatParser):
    name = "myformat"
    extensions = (".mycl",)

    def parse(self, text: str) -> Changelog:
        ...  # lenient: record problems on changelog.issues, never raise

register_format(MyFormat())
cl = patchnotes.parse(text, format="myformat")

Access releases

cl.latest()               # highest versioned release
cl.unreleased()           # [Unreleased] block, or None
cl.get_version("2.0.0")   # specific version, or None
cl.releases               # all Release objects, in file order

Query entries

r = cl.get_version("2.0.0")

r.entries          # all Entry objects
r.by_type          # dict: {"Breaking": [...], "Added": [...], ...}
r.breaking_changes # shortcut: Breaking + Removed entries
r.yanked           # bool
r.release_date     # datetime.date or None

Diff and history

# All releases strictly between 1.4.0 (exclusive) and 2.1.0 (inclusive)
releases = cl.diff("1.4.0", "2.1.0")

# All releases newer than a version (includes Unreleased)
releases = cl.since_version("1.4.0")

# Every breaking change across the entire changelog
for version, entry in cl.all_breaking_changes():
    print(f"v{version}: {entry.text}")

Serialize to JSON

cl.to_dict()        # plain Python dict, JSON-safe
cl.to_json()        # JSON string (indent=2 by default)
cl.to_json(indent=4)

Rendering

HTML

# Full standalone HTML page
html = patchnotes.to_html(cl)
with open("changelog.html", "w") as f:
    f.write(html)

# Bare <div> fragment for embedding in your own page
fragment = patchnotes.to_html(cl, full_page=False)

RSS

rss = patchnotes.to_rss(cl, project_url="https://github.com/you/project")
with open("changelog.rss", "w") as f:
    f.write(rss)

Each versioned release becomes an <item>. Unreleased entries are skipped.

Plain text

# Full summary
print(patchnotes.to_text(cl))

# Only the 3 most recent releases
print(patchnotes.to_text(cl, max_releases=3))

CLI

# Summary of all releases
patchnotes CHANGELOG.md

# Latest release
patchnotes CHANGELOG.md latest

# Unreleased changes
patchnotes CHANGELOG.md unreleased

# Specific version
patchnotes CHANGELOG.md show 2.0.0

# Diff between versions
patchnotes CHANGELOG.md diff 1.4.0 2.1.0

# All breaking changes
patchnotes CHANGELOG.md breaking

# Dump as JSON
patchnotes CHANGELOG.md json

Shell scripting

Every command accepts --format json for machine-readable output, and - reads from stdin:

# Latest version number, nothing else
patchnotes CHANGELOG.md --format json latest | jq -r .version

# Pipe from anywhere
curl -s https://raw.githubusercontent.com/user/repo/main/CHANGELOG.md \
  | patchnotes - latest

# Exit-code-only check in a script
if ! patchnotes CHANGELOG.md --quiet validate; then
    echo "changelog is broken" >&2
    exit 1
fi

Exit codes: 0 success/valid · 1 validation failed, version not found, or parse error · 2 usage error (bad arguments, missing file).

Validation in CI

patchnotes CHANGELOG.md validate            # fail on errors only
patchnotes CHANGELOG.md validate --strict   # fail on warnings too

Inside GitHub Actions, validate automatically emits ::error/::warning annotations with file and line, so problems show up inline on the PR diff. (Force this locally with --github.)


GitHub Actions

Use the bundled composite action:

# .github/workflows/validate-changelog.yml
name: Validate changelog
on:
  pull_request:
    paths: ["CHANGELOG.md"]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Londopy/patchnotes@v2
        with:
          file: CHANGELOG.md
          strict: "true"

Or plain shell (works on any CI):

      - run: |
          pip install patchnotes
          patchnotes CHANGELOG.md validate --strict

The action also exposes the latest version as an output:

      - uses: Londopy/patchnotes@v2
        id: changelog
      - run: echo "Latest release is ${{ steps.changelog.outputs.latest-version }}"

See examples/workflows/ for complete workflows, including publishing GitHub Releases from changelog notes.


Data model

Changelog
├── title: str
├── description: str
├── releases: list[Release]
│   ├── version: str
│   ├── release_date: date | None
│   ├── is_unreleased: bool
│   ├── yanked: bool
│   ├── entries: list[Entry]
│   │   ├── text: str
│   │   └── change_type: ChangeType
│   ├── by_type → dict[str, list[Entry]]
│   └── breaking_changes → list[Entry]
├── latest() → Release | None
├── unreleased() → Release | None
├── get_version(v) → Release | None
├── since_version(v) → list[Release]
├── diff(from, to) → list[Release]
├── all_breaking_changes() → list[tuple[str, Entry]]
├── validate() → list[ValidationIssue]
├── is_valid(strict=False) → bool
├── to_dict() → dict
├── to_json() → str
├── from_url(url) → Changelog
└── from_github(owner, repo, branch, filename) → Changelog

ValidationIssue
├── code: str          # stable, e.g. "PN101"
├── message: str
├── severity: "error" | "warning"
└── line: int | None

ChangeType values: Added, Changed, Deprecated, Removed, Fixed, Security, Breaking


Changelog format

patchnotes parses the Keep a Changelog spec:

# Project Name

## [Unreleased]

### Added
- New feature

## [1.2.0] - 2024-11-15

### Breaking
- Renamed `foo()` to `bar()`

### Fixed
- Some bug

## [1.1.0] - 2024-09-01 [YANKED]

### Security
- Patched CVE-2024-1234

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

patchnotes-2.0.0.tar.gz (30.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

patchnotes-2.0.0-py3-none-any.whl (27.7 kB view details)

Uploaded Python 3

File details

Details for the file patchnotes-2.0.0.tar.gz.

File metadata

  • Download URL: patchnotes-2.0.0.tar.gz
  • Upload date:
  • Size: 30.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for patchnotes-2.0.0.tar.gz
Algorithm Hash digest
SHA256 f93f70ac474a8f2c673342c3a1c9fefdb20ab42a2a0f5601e6676580a460e82a
MD5 615eae9ad67e2d6f85ed7422c6cba494
BLAKE2b-256 5c3e4b25bddbd3bdbab23d904e261826fbe6cc6f2ea28185092c29ca3e9e3159

See more details on using hashes here.

Provenance

The following attestation bundles were made for patchnotes-2.0.0.tar.gz:

Publisher: publish.yml on Londopy/patchnotes

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file patchnotes-2.0.0-py3-none-any.whl.

File metadata

  • Download URL: patchnotes-2.0.0-py3-none-any.whl
  • Upload date:
  • Size: 27.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for patchnotes-2.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 854b9d1adb1c91ac0746f2e185510bc35e693ae3f430234bcfc81dbe484f21f7
MD5 1cf8ca97b03af9aece4254406a9a433c
BLAKE2b-256 cc17406eca8e53cca5c50e1730134b1a4b335647a52bb8a978ec48cc5f6e5fd7

See more details on using hashes here.

Provenance

The following attestation bundles were made for patchnotes-2.0.0-py3-none-any.whl:

Publisher: publish.yml on Londopy/patchnotes

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page