Switching to Hatch

For a while now, I have been using Poetry in my personal projects and I love it. I loved it so much, that I recently converted an old and long-running project at work to use Poetry, too. I would consider this transition a success, and the team quickly adopted the new tool.

Unfortunately, I am a tooling nerd and enjoy experimenting with new tools. In March, I looked into PDM, but this did not spark joy. It is unclear to me why I don’t like PDM, but it’s not my favourite tool.

Hatch – a new love?

A few weeks ago, I decided to look a bit more into Hatch. Numerous people are using and telling good things about it on Mastodon. Some prominent projects I enjoy using, are using Hatch. It seems to be more standard compliant than Poetry concerning the project metadata and dependency management. And finally, people are saying it is faster than the other tools. All of these are interesting features that have triggered the tooling nerd in me.

I did some experiments with it and then decided to convert my project django-tailwind-cli to use Hatch for project management. And I also built a Django template to use Hatch to manage it too.

The transition for the project was smooth, and I am so happy, that I did it. Concerning the template, I am not certain yet. Here are some small highlights I really enjoyed.

Isolating development, testing, linting, etc.

When you create a new project with hatch new myproject it creates a new pyproject.toml file, where different tasks are separated into different environments which eventually are different virtual environments. At first, this might feel a bit overwhelming, but after a while you see it is a smart decision by the development team.

Of course, you can just remove all these environments and add dependencies for development, testing, linting, etc. to the default environment. I did so, as I did so with Poetry and basically every other tool I used before.

It was a bad decision, as I ran into a situation where the dependencies for linting interfered with the dependencies for development. So, I undid my changes and reintroduced the separation again.

# Default environment
[tool.hatch.envs.default]
dependencies = ["django-types"]

# Test environment
[tool.hatch.envs.test]
dependencies = ["django-rich", "coverage[toml]", "django~={matrix:django}.0"]

[tool.hatch.envs.test.scripts]
...

# Lint environment
[tool.hatch.envs.lint]
dependencies = ["pyright", "django-types", "curlylint", "black", "ruff"]

[tool.hatch.envs.lint.scripts]
...
# Docs environment
[tool.hatch.envs.docs]
dependencies = ["mkdocs-material"]

[tool.hatch.envs.docs.scripts]
...
TOML

With the settings above, an individual virtual environment is created for development, linting, testing and documentation. Each one is smaller and doesn’t interfere with the other. Tools can use different versions of common dependencies.

And every environment can have scripts defined. But this is a story for another section of this post.

Scripts

The NPM world can be confusing and annoying, but one thing I always loved was the ability to define complex commands, that can easily be run through NPM. Hatch offers the very same for your Python projects.

Here are some examples from django-tailwind-cli, that I use so that I don’t have to type the parameters again and again and to use them in the .pre-commit-config.yaml of the project or the GitHub actions of the project.

# Test environment
[tool.hatch.envs.test.scripts]
test = "python -m django test --settings tests.settings {args}"
test-cov = "coverage run -m django test --settings tests.settings {args}"
cov-report = ["coverage combine", "coverage report"]
cov = ["test-cov", "cov-report"]

# Lint environment
[tool.hatch.envs.lint.scripts]
run-pyright = "pyright {args:.}"
run-black = "black --quiet --check --diff {args:.}"
run-ruff = "ruff check --quiet {args:.}"
run-curlylint = "curlylint {args:.}"
python = ["run-pyright", "run-black", "run-ruff"]
templates = ["run-curlylint"]
all = ["python", "templates"]

# Docs environment
[tool.hatch.envs.docs.scripts]
build = "mkdocs build --clean --strict"
serve = "mkdocs serve"
deploy = "mkdocs gh-deploy"
TOML

Yes, I could configure the parameters in my Pre-Commit config or inside the GitHub actions, but I prefer to keep it DRY and be able to start the scripts from my main project management tool. GitHub actions and Pre-Commit are just secondary tools from my perspective.

No need to use tox

Hatch has another great feature – the matrix.

The matrix can be used in various situations. But what was striking me most, was the ability to kick Tox from my toolchain. Tox is a wonderful tool for testing your code against different versions of Python and other dependencies. Sadly, Tox and I have never been best friends. So, I was really intrigued to see, that Hatch offers all the functionality I use from Tox without using Tox.

# Test environment
[[tool.hatch.envs.test.matrix]]
python = ["3.8", "3.9", "3.10"]
django = ["3.2"]

[[tool.hatch.envs.test.matrix]]
python = ["3.8", "3.9", "3.10", "3.11"]
django = ["4.1", "4.2"]

[[tool.hatch.envs.test.matrix]]
python = ["3.12"]
django = ["4.2"]

[tool.hatch.envs.test]
dependencies = ["django-rich", "coverage[toml]", "django~={matrix:django}.0"]

[tool.hatch.envs.test.scripts]
test = "python -m django test --settings tests.settings"
test-cov = "coverage run -m django test --settings tests.settings"
cov-report = ["coverage combine", "coverage report"]
cov = ["test-cov", "cov-report"]
TOML

With the matrix and settings above, I am able to test my django-tailwind-cli project against all supported versions of Python and Django, honouring the Python version support of individual Django versions.

With this, the following calls are available to test against all versions or individual combinations of Python and Django.

# Run all tests against all Python and Django versions
hatch run test:test

# Run tests and collect coverage data against all Python and Django versions
hatch run test:cov

# Test against Python 3.10 and all supported Django versions (3.2, 4.1, 4.2)
hatch run +py=3.10 test:test

# Test against Django 4.2 and all supported Python versions (3.8 - 3.12)
hatch run +django=4.2 test:test

# Test against Python 3.11 and Django 4.2
hatch run test.py3.11-4.2:test
ShellScript

Yes, I could do the very same with tox, but I would have to use another tool for a rather simple use case.

Optional dependencies

In my django-hatch-startproject Django template, I use the optional dependencies as defined by PEP 621 to offer support for MySQL/MariaDB and Postgres. Nothing special or Hatch specific about that.

[project.optional-dependencies]
mysql = [
  "mysqlclient>=2.2.0",
  "django-mysql>=4.10.0",
]
postgres = [
  "psycopg[binary]>=3.1.10",
]
TOML

But Hatch offers a neat way to reference these optional dependencies in your default environment used during development or any other environment that requires them.

# Default environment
[tool.hatch.envs.default]
dependencies = ["django-types", "ipdb", "model-bakery"]
features = [
  # Uncomment the next line to add the MySQL dependencies
  "mysql",
  # Uncomment the next line to add the Postgres dependencies
  # "postgres",
]
TOML

I find this quite handy during development and to roll out optional dependencies in this template.

Hatch – everything is fine?

So, everything is wonderful and perfect with Hatch? No, even though the tool itself is great and works almost perfectly for me, it was quite hard to reach the current point. My main criticism goes to the documentation. Don’t get me wrong, basically everything is documented, but some examples are a bit confusing. For example, I didn’t understand the matrix by reading the documentation, but by reading the django-wiki code. But on the other hand, this is something that can be easily improved and can be done by a Hatch-newbie like me. I added some tasks to my todo list already and hope to contribute in the coming weeks and moths.

For me, Hatch is the better tool to use for project management than Poetry. I love the options I have in the configuration, I love the environment isolation and management, and I love the speed of the tool.