Keep a Changelog
Table Of Contents
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:
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:
version
– the major project version (see official documentation),release
– the full project version (see official documentation).
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
:
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.
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.