Semantic Versioning In Python With Git Hooks

I write content for AWS, Kubernetes, Python, JavaScript and more. To view all the latest content, be sure to visit my blog and subscribe to my newsletter. Follow me on Twitter.

This is Day 22 of the #100DaysOfPython challenge.

This post will use the pre-commit and commitizen packages to automate our semantic versioning for Python based on our commits.

The post will build off the work done in the post "Your First Pip Package With Python".

The final code can be found here.

Prerequisites

  1. Familiarity with Pipenv. See here for my post on Pipenv.

Getting started

We are going to clone and work from the okeeffed/your-first-pip-package-in-python repository and install the required packages.

$ git clone https://github.com/okeeffed/your-first-pip-package-in-python semantic-versioning-in-python-with-git-hooks
$ cd semantic-versioning-in-python-with-git-hooks

# Init the virtual environment
$ pipenv install --dev pre-commit Commitizen toml

# Create some required files
$ touch .pre-commit-config.yaml

At this stage, we are ready to configure our pre-commit hook.

Pre-commit configuration

We need to add the following to .pre-commit-config.yaml:

---
repos:
  - repo: https://github.com/commitizen-tools/commitizen
    rev: master
    hooks:
      - id: commitizen
        stages: [commit-msg]

Once done, we can setup our Commitizen configuration.

Setting up Commitizen

We can set this up by running pipenv run cz init:

$ pipenv run cz init
? Please choose a supported config file: (default: pyproject.toml) pyproject.toml
? Please choose a cz (commit rule): (default: cz_conventional_commits) cz_conventional_commits
No Existing Tag. Set tag to v0.0.1
? Please enter the correct version format: (default: "$version")
? Do you want to install pre-commit hook? Yes
commitizen already in pre-commit config
commitizen pre-commit hook is now installed in your '.git'

You can bump the version and create changelog running:

cz bump --changelog
The configuration are all set.

We are now at a stage where our commits can affect our versioning! A file is created for us pyproject.toml:

[tool]
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.0.1"
tag_format = "$version"

This will contain the version information that is maintained by Commitizen for us.

Automated semantic versioning at work

First of all, we will need to create a tag for version 0.0.1:

$ git tag -a 0.0.1 -m "Init version"

Our version will change based on the naming that we give our commits. The installed Git hook will enforce that our commits follow the Conventional Commits naming conventions.

Let's see this in action. First, let's add a new function to the file demo_pip_math/math.py:

def divide(x: int, y: int) -> float:
    """divide one number by another

    Args:
                    x (int): first number in the division
                    y (int): second number in the division

    Returns:
                    int: division of x and y
    """
    return x / y

We want to add and commit this code. Let's see what happens when we set an invalid version:

$ git add demo_pip_math/math.py
$ git commit -m "added new feature division"
commitizen check.........................................................Failed
- hook id: commitizen
- exit code: 14

commit validation: failed!
please enter a commit message in the commitizen format.
commit "": "not a valid commit name"
pattern: (build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)(\(\S+\))?!?:(\s.*)

This tells use that we did not match the expected pattern.

Given that we are adding a new feature, let's try with the feat: prefix:

$ git commit -m "feat: added new divide functionality"
commitizen check.........................................................Passed
[main 333d291] feat: added new divide functionality
 1 file changed, 13 insertions(+)

We've passed!

Now that we've made a change, we can use Commitizen to help us update our version and create a Changelog and update the version:

$ pipenv run cz bump
bump: version 0.0.10.1.0
tag to create: 0.1.0
increment detected: MINOR

Done!

If we now check pyproject.toml, we can see that change reflected for us:

[tool]
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.1.0"
tag_format = "$version"

Creating the Changelog

To create a Changelog, run pipenv run cz changelog:

$ pipenv run cz changelog
# ... no output

This will create a CHANGELOG.md file in our project root directory, now with the following information:

## 0.1.0 (2021-08-10)

### Feat

- added new divide functionality

## 0.0.1 (2021-08-10)

### Feat

- add in division function
- init commit

Success!

Keeping setup.py in sync

Finally, we want to make sure that our setup.py is in sync with what is reflected in pyproject.toml.

We can do this with the toml package.

Inside of setup.py, change the file to be the following:

import setuptools
import toml
from os.path import join, dirname, abspath

pyproject_path = join(dirname(abspath("__file__")),
                      '../pyproject.toml')
file = open(pyproject_path, "r")
toml_str = file.read()

parsed_toml = toml.loads(toml_str)

with open("README.md", "r") as fh:
    long_description = fh.read()

setuptools.setup(
    name="demo_pip_math",
    version=parsed_toml['tool']['commitizen']['version'],
    author="Dennis O'Keeffe",
    author_email="[email protected]",
    description="Demo your first Pip package.",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/okeeffed/your-first-pip-package-in-python",
    packages=setuptools.find_packages(),
    classifiers=[
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
    ],
    python_requires='>=3.6',
    keywords='pip-demo math',
    project_urls={
        'Homepage': 'https://github.com/okeeffed/your-first-pip-package-in-python',
    },

)

We are reading in the TOML and finding the reference to the current version.

We can check that this still works as expected with a build script that was already added in the repo:

$ pipenv run build
running sdist
running egg_info
creating demo_pip_math.egg-info
writing demo_pip_math.egg-info/PKG-INFO
writing dependency_links to demo_pip_math.egg-info/dependency_links.txt
writing top-level names to demo_pip_math.egg-info/top_level.txt
writing manifest file 'demo_pip_math.egg-info/SOURCES.txt'
reading manifest file 'demo_pip_math.egg-info/SOURCES.txt'
reading manifest template 'MANIFEST.in'
adding license file 'LICENSE'
writing manifest file 'demo_pip_math.egg-info/SOURCES.txt'
running check
creating demo_pip_math-0.1.0
creating demo_pip_math-0.1.0/demo_pip_math
creating demo_pip_math-0.1.0/demo_pip_math.egg-info
creating demo_pip_math-0.1.0/tests
copying files to demo_pip_math-0.1.0...
copying LICENSE -> demo_pip_math-0.1.0
copying MANIFEST.in -> demo_pip_math-0.1.0
copying README.md -> demo_pip_math-0.1.0
copying pyproject.toml -> demo_pip_math-0.1.0
copying setup.py -> demo_pip_math-0.1.0
copying demo_pip_math/__init__.py -> demo_pip_math-0.1.0/demo_pip_math
copying demo_pip_math/math.py -> demo_pip_math-0.1.0/demo_pip_math
copying demo_pip_math.egg-info/PKG-INFO -> demo_pip_math-0.1.0/demo_pip_math.egg-info
copying demo_pip_math.egg-info/SOURCES.txt -> demo_pip_math-0.1.0/demo_pip_math.egg-info
copying demo_pip_math.egg-info/dependency_links.txt -> demo_pip_math-0.1.0/demo_pip_math.egg-info
copying demo_pip_math.egg-info/top_level.txt -> demo_pip_math-0.1.0/demo_pip_math.egg-info
copying tests/__init__.py -> demo_pip_math-0.1.0/tests
copying tests/test_math.py -> demo_pip_math-0.1.0/tests
Writing demo_pip_math-0.1.0/setup.cfg
creating dist
Creating tar archive
removing 'demo_pip_math-0.1.0' (and everything under it)
running bdist_wheel
running build
running build_py
creating build
creating build/lib
creating build/lib/demo_pip_math
copying demo_pip_math/__init__.py -> build/lib/demo_pip_math
copying demo_pip_math/math.py -> build/lib/demo_pip_math
creating build/lib/tests
copying tests/__init__.py -> build/lib/tests
copying tests/test_math.py -> build/lib/tests
warning: build_py: byte-compiling is disabled, skipping.

installing to build/bdist.macosx-11-x86_64/wheel
running install
running install_lib
creating build/bdist.macosx-11-x86_64
creating build/bdist.macosx-11-x86_64/wheel
creating build/bdist.macosx-11-x86_64/wheel/demo_pip_math
copying build/lib/demo_pip_math/__init__.py -> build/bdist.macosx-11-x86_64/wheel/demo_pip_math
copying build/lib/demo_pip_math/math.py -> build/bdist.macosx-11-x86_64/wheel/demo_pip_math
creating build/bdist.macosx-11-x86_64/wheel/tests
copying build/lib/tests/__init__.py -> build/bdist.macosx-11-x86_64/wheel/tests
copying build/lib/tests/test_math.py -> build/bdist.macosx-11-x86_64/wheel/tests
warning: install_lib: byte-compiling is disabled, skipping.

running install_egg_info
Copying demo_pip_math.egg-info to build/bdist.macosx-11-x86_64/wheel/demo_pip_math-0.1.0-py3.9.egg-info
running install_scripts
adding license file "LICENSE" (matched pattern "LICEN[CS]E*")
creating build/bdist.macosx-11-x86_64/wheel/demo_pip_math-0.1.0.dist-info/WHEEL
creating 'dist/demo_pip_math-0.1.0-py3-none-any.whl' and adding 'build/bdist.macosx-11-x86_64/wheel' to it
adding 'demo_pip_math/__init__.py'
adding 'demo_pip_math/math.py'
adding 'tests/__init__.py'
adding 'tests/test_math.py'
adding 'demo_pip_math-0.1.0.dist-info/LICENSE'
adding 'demo_pip_math-0.1.0.dist-info/METADATA'
adding 'demo_pip_math-0.1.0.dist-info/WHEEL'
adding 'demo_pip_math-0.1.0.dist-info/top_level.txt'
adding 'demo_pip_math-0.1.0.dist-info/RECORD'
removing build/bdist.macosx-11-x86_64/wheel

We can see that version 0.1.0 is used in this build.

Summary

Today's post demonstrated how to use the commitizen, pre-commit and toml packages to help automate our versioning process based on Conventional Commits.

This helps larger teams keep their packages semantically correct with little effort added to their work.

Resources and further reading

Photo credit: snapsbyclark

Originally posted on my blog. To see new posts without delay, read the posts there and subscribe to my newsletter.

22