pre-commit, Python typing & virtual envs¶
The pre-commit package is a great way to quickly get a repo running with lots of checks you might not have even thought about, and shouldn't have to spend time manually coding into hooks yourself. It's certainly helping keep my projects extra-squeaky-clean in a way that feeds back to my brain in the form of lots of little dopamine hits.
Check out the .pre-commit-config.yaml file behind this site's codebase to see what I'm actually using on it today.
My initial concern, though, was that pre-commit install
does of course take over the pre-commit hooks for a repository, because it relies on its own .git/hooks/pre-commit
executable script – and it wasn't immediately obvious how I could just add my own arbitrary checks.
TL;DR?¶
"Repository local hooks" are your friend.
Invisible virtual environments¶
One slightly magical, opaque implementation detail about pre-commit is that every hook basically creates and runs in its own virtual environment. What that looks like depends on the "language" associated with the hook itself, and there are a couple of notable exceptions:
- "script" hooks, for simple scripts used to validate files,
- "system" hooks, which target system-level executables, and
- Repository-local hooks, most useful when the hook itself depends on build artefacts in your project, or fairly "complete" virtual environments that you don't want to re-build just for that hook.
Additional dependencies¶
If a hook isn't working the way you'd expect, it may be due to the fact that the virtual environment its creating for itself doesn't have quite everything it needs to run – this is where the additional_dependencies
lists come into your configuration. Most hook repositories seem pretty good about mentioning the kind of things you'll need to add here – for example I got my eslint
hook running with just:
- repo: https://github.com/pre-commit/mirrors-eslint rev: "v9.18.0" hooks: - id: eslint name: Lint ECMAScript with eslint args: ["--config", "./eslint.config.mjs"] additional_dependencies: - eslint@9.16.0 - eslint-plugin-prettier@5.2.1 - eslint-plugin-tailwindcss@3.17.5 - eslint-plugin-react@7.37.2 - typescript-eslint@8.17.0
Static typing, and just using 'local'¶
It was only after spending some time trying to get mypy for pre-commit working that the penny really dropped for me, with regard to how much of a working virtualenv some things actually need. Calling mypy .
was finally going smoothly, with no more configuration required than what was in my pyproject.toml
:
[tool.mypy] exclude = [ '^(.git|.pytest_cache|.ruff_cache|.venv)/.*', '^(build|dist|infra|lib|logs|media|node_modules|snapshots|static)/.*', ] mypy_path = "$MYPY_CONFIG_FILE_DIR/src" plugins = ["mypy_django_plugin.main"] ignore_missing_imports = true [tool.django-stubs] django_settings_module = "picata.settings.mypy"
… but the mypy pre-commit hook was complaining about one more required dependency after another, until I realise I realised I was about to completely re-create pretty much my entire Python virtual environment, invisibly, in the background, just for this hook to run.
And it's not like mypy
was going to change anything, so the principle of isolating pre-commit hooks in their own virtual environments for safety (or whatever) reasons seemed moot. Surely there had to be a better way?
The better way¶
As you might have guessed by now, the better way came in the form of abandoning the mypy pre-commit hook package completely, just using repo: local
, and telling pre-commit what it was I wanted it to run against my already-built virtual environment:
- repo: local hooks: - id: mypy name: Type-check Python with mypy language: python types: [python] entry: mypy args: ["--config-file=pyproject.toml"]
So, yeah. For many pre-commit checks, it might be easier to not go looking for a third-party repository, and just call what you want, when you want, how you want.
❯ pre-commit run mypy --all-files Type-check Python with mypy..............Passed