Keep a Changelog

6 min Kamil Rusin

Recently I was tasked with initialization of a new Python project. One of the requirements was to prepare a changelog. I liked the idea, but my gut feeling was that the future project contributors won’t keep the changelog always up-to-date and it’s quality will decline over time. This article proposes a feature which enforces changelog updates, when it’s required to do so.

# What’s a changelog

A changelog is a log or record of all notable changes made to a project. The project is often a website or software project, and the changelog usually includes records of changes such as bug fixes, new features, etc.

from Wikipedia.

The phrase “notable changes” is the key here. Not all changes to the project are required to have an entry in its changelog: workflow updates, typo fixes in README, SonarQube settings; they all are transparent to the Python package produced by the project.

Let’s take a look at an example:

 1.
 2├─ .github
 3│ └─ workflows
 4│   └── workflow.yaml
 5├─ docs
 6│ ├─ conf.py
 7│ ├─ index.rst
 8│ └─ Makefile
 9├─ foo
10│ ├─ __init__.py
11│ └─ bar
12│   ├─ __init__.py
13│   └─ baz.py
14├─ tests
15│ ├─ __init__.py
16│ └─ test_bar.py
17├─ .gitignore
18├─ README.md
19├─ Makefile
20├─ setup.py
21├─ MANIFEST.in
22├─ pyproject.toml
23└─ Changelog.md

This is a pretty standard (and also minimalistic!) take on a Python project. The pipeline defined in .github/workflows/workflow.yaml builds, tests and publishes the package. Initially project stored its version in pyproject.toml, while Changelog.md contained human-readable lists of additions, fixes, and improvements for each release. Having a standard usually makes things easier, so we adopted keep a changelog, which also uses Semantic Versioning 2.0.0, so that was nice.

Keep a changelog is just a Markdown template – it has no associated toolset. Here’s an example of a changelog with two entries:

 1# Changelog
 2
 3All notable changes to this project will be documented in this file.
 4
 5The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 6and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 7
 8## [1.2.0] - 2023-10-01
 9
10### Added
11
12- Support for Python 3.12.
13- Utility for parsing `foo`.
14
15### Fixed
16
17- Worker subprocesses are now correctly closed.
18
19### Changed
20
21- Update `pylint` version to `~= 2.17`.
22
23## [1.1.2] - 2019-02-15
24
25### Fixed
26
27- Typo in project's title in docs.

# Keep(ing) a changelog up-to-date

When releasing a new package version one needs to remember to update the version in two places: pyproject.toml and Changelog.md. Also, there’s no mechanism to enforce that behavior; we could create a workflow step to validate that both places are in sync, but relaying on CI to inform contributors about the necessary changes seems far from ideal.

Instead, we made Changelog.md the only place where the version can be updated. pyproject.toml expects the version to be statically defined; we can disable that behavior by marking the version as dynamic.

 1[build-system]
 2requires = ["setuptools", "wheel"]
 3build-backend = "setuptools.build_meta"
 4
 5[project]
 6name = "foo"
 7readme = "README.md"
 8requires-python = ">=3.9"
 9dependencies = [
10    "requests",
11]
12dynamic = ["version"]  # <----

The version still needs to be defined somewhere, and using setup.py for that purpose gives us an advantage – we can execute an arbitrary Python code there.

 1import re
 2from pathlib import Path
 3from typing import Generator
 4
 5from setuptools import setup
 6
 7
 8def __get_versions() -> Generator[tuple[int, int, int], None, None]:
 9    dir_name = Path(__file__).parent
10    with open(dir_name / "Changelog.md", "r") as file:
11        lines = file.readlines()
12
13    for line in lines:
14        m = re.match(r"^##\s+\[(\d+\.\d+\.\d+)\]", line)
15        if m:
16            x, y, z = m.group(1).split(".", 2)
17            yield int(x), int(y), int(z)
18
19
20def __get_highest_version() -> str:
21    highest_version = max(__get_versions())
22    return ".".join(map(str, highest_version))
23
24
25setup(
26    version=__get_highest_version(),
27)

Now the only place where package’s version needs to be updated is the changelog itself. Note that this change does not enforce updating the changelog for every change. However, to release a new version of the package one is required to add a new changelog entry.

Now, since the presence of Changelog.md is necessary for the build to succeed, we should inform the build system about this dependency. MANIFEST.in contains include/exclude patters for non-standard files (see official docs). Add the following line to the manifest:

1# ...
2include Changelog.md

# Accessing project version from Sphinx

Sphinx is a most popular tool for generating documentation out of Python source files. It keeps its configuration in the conf.py file, which has two special attributes related to the project’s version:

With the current setup it’s hard for Sphinx to read the version, since it’s only accessible from setup.py. The easiest workaround is to extract the parsing to an external module and then use that module as a dependency in both conf.py and setup.py.

 1# version.py
 2import re
 3from pathlib import Path
 4from typing import Generator
 5
 6from setuptools import setup
 7
 8
 9def __get_versions() -> Generator[tuple[int, int, int], None, None]:
10    dir_name = Path(__file__).parent
11    with open(dir_name / "Changelog.md", "r") as file:
12        lines = file.readlines()
13
14    for line in lines:
15        m = re.match(r"^##\s+\[(\d+\.\d+\.\d+)\]", line)
16        if m:
17            x, y, z = m.group(1).split(".", 2)
18            yield int(x), int(y), int(z)
19
20
21def __get_highest_version() -> str:
22    highest_version = max(__get_versions())
23    return ".".join(map(str, highest_version))
24
25
26version = __get_highest_version()

After that we can import the module in setup.py:

1from setuptools import setup
2
3from version import version
4
5setup(
6    version=__get_highest_version(),
7)

… and conf.py:

 1import sys
 2from pathlib import Path
 3
 4project = "foo"
 5copyright = "2023"
 6author = "Kamil Rusin"
 7
 8root_dir = Path(__file__).parents[1]
 9sys.path.insert(0, str(root_dir))
10
11from version import version as __project_version
12
13version = __project_version
14release = version

Again, we cannot forget about informing the build system about the new dependency in MANIFEST.in:

1# ...
2include Changelog.md
3include version.py

# Afterthought

Maintaining a changelog can make a project easier to understand. Hopefully, with the changes proposed in this article you’ll find changelogs more manageable. This feature does not enforce changelog updates from the contributors – they will only need to do so, when they want to release a new version of the package.

I highly encourage to always release a new version even for the smallest change in the project sources that is observable for the users. Whether it’s worth to add changelog entries for project’s meta updates, is up to you.

Log

Photo by Jasper Garratt on Unsplash

Interested in my work?

Consider subscribing to the RSS Feed or joining my mailing list: madebyme-notifications on Google Groups .


Disclaimer: Only group owner (i.e. me) can view e-mail addresses of group members. I will not share your e-mail with any third-parties — it will be used exclusively to notify you about new blog posts.