Python best practices guide
This manual is designed to aid developers in writing Python code that is clear and consistent, within, and across, projects at MHCLG.
- uv
- Environments
- Code formatting
- Maximum line length of 120 characters
- Linting
- Type hints
- Dependencies
uv
This manual advises the use of uv
to manage Python projects. Read the linked documentation for more information. Using uv
consistently will make it easier for developers in MHCLG to move between different teams by providing a similar way of managing the project and its dependencies.
Once installed, the uv
command lets you manage your installed Python versions, your virtual environments, and your project’s dependencies.
uv python install <version>
to explicitly install a version of Python. However, one of uv
’s stated benefits is that it will generally do everything for you. You should not need to install Python manually, or manage your virtual environments manually.
Any existing Python projects in MHCLG that do not use uv
should aim to move over when it is convenient.
Environments
uv
will manage your Python virtual environment, which ensures that each project’s dependencies are isolated from other projects on your system. Using any uv
command while within the Python project will automatically install the correct Python version and sync your dependencies before doing the command you used.
direnv is used to manage environment variables. This ensures project specific variables do not clutter your main environment or the environment of other projects. direnv uses .envrc
for general project specific variables and you can use a non version controlled .secrets
to store sensitive information.
Code formatting
We use Ruff to format our code. Ruff is an opinionated formatter that broadly follows PEP 8; where Ruff and PEP 8 do not express a view (for example, on the usage of language features such as metaclasses) we defer to the Google Python style guide. Use these as references unless something is explicitly mentioned here. These rules should be followed in conjunction with the advice on consistency on the main programming languages manual page.
If you want to add a new rule or exception please create a pull request against this repo.
Maximum line length of 120 characters
PEP 8 dictates a preferred maximum line length of <= 79. This is is a hangover from developing in a Unix terminal window. The vast majority of developers are now using an IDE which can handle a greater line length.
Couple this with the fact that much of the time MHCLG developers are coding web apps and have to deal with nested JSON
objects, ORM model definitions/ queries, and error/ url strings and this convention begins to show its age.
Linting
Ruff
This manual advises the use of the Ruff command line checker as an all in one lint, codestyle and complexity checker.
How to use Ruff
First you should add the Ruff module (available from PyPI) to your dev
or test
requirements/dependencies.
You’ll then likely want to run it alongside your unit tests.
Ruff ignores
Ruff can ignore particular lines or files.
A particularly useful feature of ruff is the ability to specify rule exemptions per directory or file. Commonly it’s used for ignoring unused imports in module level __init__.py
files or imports not being at the top of a file in settings files or scripts.
The feature is documented in the Ruff documentation, under per-file-ignores. You can also see an example in the Funding Service - Post Award repo.
Common Configuration
The Funding Service is already running the latest verions of Ruff on all of its repos. You can find an example of their configuration in the root of any repo in the pyproject.toml
file.
Commonly a pyproject.toml
configuration file will live in the root of the package. It will contain separate sections for each tool the repository needs
[tool.ruff]
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle
"W", # pycodestyle
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C90", # mccabe cyclomatic complexity
"G", # flake8-logging-format
]
ignore = []
exclude = [
]
In the above file we exclude directories we want the checker to ignore completely, include optional linting such as applying isort rules to ensure imports are sorted for minimal git diffs, set the maximum line length and set the target Python version.
Note: you can also ignore rules on particular lines of code or files by adding a # noqa
comment - see ruff’s noqa syntax.
Additional linting resources
- Funding Service - Post Award ruff configuration: A production config to base off
- Ruff error codes list
Type hints
Python is a dynamically-typed language, which can allow for fast and easy development in the early days. However, the flexibility can quickly become hard to understand when a project has developed for a number of years across a number of developers. Therefore, all new projects other than prototypes or very short-lived things, should make use of type hints from the beginning. We recommend using MyPy with strict
checks enabled as soon as possible. The longer you wait to turn on strict type hints, the more painful it may become if insufficient thought has been paid to appropriate type choices.
We recommend running Mypy as a check in your CI tests, and optionally also a pre-commit check.
Example configuration
Here is an example configuration for a new project in your pyproject.toml
:
[tool.mypy]
python_version = "3.11"
strict = true
With an existing codebase
Existing projects should follow Mypy’s guidance around using it with an existing codebase, and aim to get minimal checks enabled as soon as possible, with a plan to work towards enforcing the strict checks as development allows. This is not a small undertaking and may take some time to complete. Use your judgement to work out which areas may most benefit from type checking first, and prioritise those.
Dependencies
You should use uv
to manage your Python project and its dependencies.
A Python application project typically brings together Python packages from PyPI, with others written in-house (or otherwise not distributed through PyPI).
These packages are the application’s immediate dependencies. Additionally, any package can have its own immediate dependencies, where they draw on other packages. From an application’s point of view, the dependencies of the packages it requires are its sub-dependencies.
The below diagram shows a simplified view of the resulting pyramid of dependencies; however it is important to note that the hierarchy can repeat itself infinitely.
Application
|
+-----+-----+
| | |
Immediate dependencies
| | |
+--+--+--+--+--+--+--+--+
| | | | | | | | |
... Sub-dependencies ...
There are two ways of specifying dependencies in Python world: as a specific version or as an allowable range.
Different considerations apply to dependency management depending whether you are packaging a library, or creating an end-product such as an application or a bundle of scripts. In general, you should only specify specific versions when creating a Python system that sits at the top of the dependency pyramid; otherwise there is a danger of creating version conflicts.
Applications
These recommendations apply wherever you need a reproducible set of dependencies, such as a complete web application, perhaps with many dependencies and sub-dependencies. It also applies to a collection of scripts that are deployed into the cloud and run automatically (for example, batch jobs).
A good strategy for specifying your application’s Python dependencies has two desirable characteristics - they should be:
Reproducible (predictable)
Pin your application’s full dependencies – specific versions, rather than ranges – or you’ll get unpredictability between your dev environment and other environments. You want a new starter to avoid small hard-to-spot problems. And you want parity between what you test locally, what is tested by CI, and what you deploy, or you risk new issues appearing on a live server. Additionally these things can be hard to diagnose.
Kept up-to-date
Security issues are found in libraries, so it is important to choose libraries that are maintained and to ensure your team has a strategy to ensure security updates are installed without significant delay. The how to manage third party software dependencies section gives further context and discusses tools that can help.
Your README should document an easy-to-follow process by which all your Python dependencies can be upgraded to get bug fixes and security fixes, without introducing breaking changes to your build.
Your direct dependencies should be specified in pyproject.toml
under the project.dependencies
key. You can manage these with uv [add|remove] <package>
. Your development dependencies should also be in pyproject.toml
under the project.optional-dependencies
key; you can manage these with uv [add|remove] --dev <package>
. uv
will create a lockfile called uv.lock
that specifies the exact versions of all direct and transitive dependencies that your project requires. This should be git committed.
Libraries
This recommendation applies to any Python repository that intended to be installable (into a virtual environment, a container, or onto bare metal) as a dependency of some larger system or application. It may be applicable to repositories that provide scripts to be run by developers or other end-users, but is not recommended for code that’s intended to be deployed on its own into the cloud.
Use uv
to add/update/remove the dependencies of your library, and declare the version ranges with which it can be reasonably expected to work.
The range you choose will depend on the guarantees each dependency makes about backward-compatibility. For example, if you’re currently using version 1.3.1 of a semantically-versioned library, it would be reasonable to specify a range such as
>=1.3.1,<2.0
. However, for a library that does not make that guarantee, you might specify a more restricted range, such as>=1.3.1,<1.4
.Update this file whenever you are ready to test and validate a new version that falls outside the existing range.
If you have dependencies that are not available on PyPI (for example, because you’ve fixed a bug by forking the code), then you can use a PEP 440 git reference in your
install_requires
list.
Specify dependencies needed only for testing your library in tox.ini
if you are using Tox
(example),
or in a requirements-dev.txt
(example)
file otherwise.