Semantic Versioning In Python With Git Hooks
Tags: python, 100daysofpython
Published: 2021-09-22 10:15:14

_**I write content for AWS, Kubernetes, Python, JavaScript and more. To view all the latest content, be sure to [visit my blog](https://blog.dennisokeeffe.com) and subscribe to my newsletter. [Follow me on Twitter](https://twitter.com/dennisokeeffe92).**_

> This is Day 22 of the #100DaysOfPython challenge.

This post will use the [`pre-commit`](https://pre-commit.com/) and [`commitizen`](https://github.com/commitizen-tools/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"](https://blog.dennisokeeffe.com/blog/2021-08-09-your-first-pip-package-in-python).

The final code can be found [here](https://github.com/okeeffed/semantic-versioning-in-python-with-git-hooks).

## Prerequisites

1. Familiarity with [Pipenv](https://pipenv.pypa.io/en/latest/). See [here](https://blog.dennisokeeffe.com/blog/2020-07-04-abcs-of-pipenv) for my post on Pipenv.

## Getting started

We are going to clone and work from the [`okeeffed/your-first-pip-package-in-python`](https://github.com/okeeffed/your-first-pip-package-in-python) repository and install the required packages.

```s
$ 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`:

```yml
---
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`:

```s
$ 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`:

```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`:

```s
$ 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](https://www.conventionalcommits.org/en/v1.0.0/) naming conventions.

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

```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:

```s
$ 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:

```s
$ 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:

```s
$ pipenv run cz bump
bump: version 0.0.1 → 0.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:

```toml
[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`:

```s
$ pipenv run cz changelog
# ... no output
```

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

```md
## 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:

```py
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="hello@dennisokeeffe.com",
    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:

```s
$ 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

1. [The ABCs of Pipenv](https://blog.dennisokeeffe.com/blog/2020-07-04-abcs-of-pipenv)
2. [Project code](https://github.com/okeeffed/semantic-versioning-in-python-with-git-hooks)
3. [`pre-commit`](https://pre-commit.com/)
4. [`commitizen`](https://github.com/commitizen-tools/commitizen)
5. [Your First Pip Package With Python](https://blog.dennisokeeffe.com/blog/2021-08-09-your-first-pip-package-in-python)

_Photo credit: [`snapsbyclark`](https://unsplash.com/@snapsbyclark)_


_Originally posted on my [blog](https://blog.dennisokeeffe.com/blog/2021-08-10-semantic-versioning-in-python-with-git-hooks). To see new posts without delay, read the posts there and subscribe to my newsletter._