pyproject.toml is a powerful configuration file format for Python projects that enhances project organization, build specifications, and dependency management.
It was introduced as part of PEP 518 and has become the standard for modern Python projects, earning its place as the default in major tools like Poetry, Hatch, and PDM.
With its broad ecosystem support and clean, readable syntax, pyproject.toml offers a vastly improved configuration experience compared to the legacy setup.py approach.
This article will guide you using pyproject.toml to create a well-structured Python project. You'll learn how to leverage its features and customize configurations to build a project that adheres to industry best practices.
Prerequisites
Before diving into the rest of this article, ensure you have a recent version of Python (3.13+) and pip installed on your machine. This article also assumes a basic understanding of Python and dependency management.
The history and purpose of pyproject.toml
For years, Python packaging was fragmented, relying on multiple configuration files like setup.py, requirements.txt, and tool-specific configs.
This led to inconsistencies, security risks, and maintenance challenges. In particular, the setup.py approach suffered from arbitrary code execution risks, bootstrapping issues, and lack of standardization.
To address these problems, PEP 518 introduced pyproject.toml in 2016, providing a standardized way to define build dependencies. Over time, additional PEPs refined its role, with significant tools like Poetry, Flit, and setuptools adopting it as a central configuration file.
Switching from setup.py to a declarative approach, pyproject.toml enhances security, simplifies dependency management, promotes consistency, and centralizes configuration for development tools.
Getting started with pyproject.toml
To get the most out of this tutorial, create a new Python project to try out the concepts we'll discuss.
Start by initializing a new project structure using the commands below:
Create the virtual environment:
Activate the virtual environment:
Now, create a basic pyproject.toml file in the root of your project directory:
This snippet defines the build system requirements and basic project metadata. We'll explore all the different ways you can customize this configuration, but for now, let's set up a minimal package structure:
Create a simple module file:
Now, verify your setup by installing the package in development mode:
Once installed, you can test the package in a Python interpreter. Open the shell with the following command:
Then, run the following code:
The first thing you'll notice about pyproject.toml is that it uses TOML, a minimal configuration file format designed to be easy to read and write.
Unlike setup.py, which is executable Python code, pyproject.toml is a declarative specification, which eliminates many potential security issues and makes project configurations more consistent.
Understanding the core sections in pyproject.toml
Now that you've set up a basic project, let's explore the main sections of pyproject.toml in detail. Understanding these sections is crucial for effectively configuring your Python projects.
The build-system section
The [build-system] section is mandatory according to PEP 518. It specifies which build tools are required to build your package and which backend to use:
The requires field lists the packages needed for building (not running) your package. The build-backend specifies which tool will interpret your build instructions. While setuptools is the most common backend, alternatives like Hatchling, Poetry, or Flit can also be used.
The project section
The [project] section contains metadata about your package and replaces the previous setuptools setup() call:
This section includes package name, version, description, and other metadata that will be used when publishing to the Python Package Index (PyPI). The requires-python field helps users know which Python versions are supported by your package.
Managing dependencies
One of the most significant improvements in pyproject.toml is how it handles dependencies. Dependencies are specified in the [project.dependencies] section:
For optional features or development dependencies, you can use the [project.optional-dependencies] section:
This approach lets users install extra dependencies only when needed:
Entry points and scripts
To create command-line scripts that can be called directly after installation, use the [project.scripts] section:
This configuration makes a command called pyproject-demo available on the system path after installation, which calls the main() function in the pyproject_demo.cli module.
For more complex plugin registration or entry points that other packages can discover, use the [project.entry-points] section:
Tool-specific configuration in pyproject.toml
Beyond the standard project metadata and dependency management, one of the most powerful features of pyproject.toml is its ability to house configuration for various development tools. This approach centralizes your project settings, eliminating the need for multiple configuration files scattered throughout your project directory.
Black code formatter
Black is a popular Python code formatter that enforces a consistent style across your codebase. You can configure Black directly in your pyproject.toml file:
This configuration sets the maximum line length to 88 characters (Black's default), specifies which Python versions to target, and defines patterns for files to include or exclude from formatting. Black's native support for pyproject.toml makes it a perfect example of how modern Python tools are embracing this configuration approach.
MyPy type checking
MyPy is a static type checker for Python that helps catch type-related errors before runtime. Its configuration in pyproject.toml is straightforward:
This configuration enforces strict type checking for your project code while relaxing the requirements for test files. The disallow_untyped_defs option ensures all functions have type annotations, improving code quality and documentation. With the override section, you can apply different rules to specific modules, making it easier to adopt type checking in larger projects gradually.
Ruff
Ruff is a fast Python linter written in Rust that aims to replace multiple tools like Flake8, isort, and more. Its configuration in pyproject.toml is comprehensive:
This configuration enables specific rule sets (E for style errors, F for flake8 rules, and B for bugbear rules), ignores certain rules (like E501 for line length), and configures import sorting behavior. Ruff's unified approach to linting demonstrates the value of having a single configuration file for all your development tools, as it can replace multiple separate linters with a single, faster implementation.
Combining multiple tools
The real power of pyproject.toml comes from centralizing all these configurations in one place. Here's how they look together:
This unified approach offers several advantages:
- Single source of truth: All configurations are in one file, making it easier to maintain consistency.
- Reduced cognitive load: Developers don't need to remember multiple file formats and locations.
- Easier onboarding: New team members can quickly understand the project's tooling setup.
- Version control efficiency: Changes to tool configurations are tracked together, simplifying code reviews.
Most modern Python development tools now support pyproject.toml configuration, reflecting a community-wide shift toward standardization and simplification.
As you develop your Python projects, leveraging this centralized configuration approach can significantly improve your development workflow and project maintainability.
Dynamic versioning with pyproject.toml
One common challenge in Python projects is maintaining consistent version numbers across your package. Repeating the version in multiple places can lead to inconsistencies when one location is updated but others are forgotten.
Let's explore how to implement dynamic versioning with pyproject.toml to solve this problem.
Using a dedicated version file
A good practice is to create a single source of truth for your version number:
Then reference this version in your pyproject.toml file:
With this approach, you declare that the version is "dynamic" in the [project] section and specify its source in the [tool.setuptools.dynamic] section. This configuration tells setuptools to read the version from the __version__ variable in the pyproject_demo._version module at build time.
The primary benefit of this method is that both your code and build system reference the same version string. Your package can access its version at runtime using:
Using SCM-based versioning
For more advanced projects, you can use tools like setuptools-scm, which automatically derives your package version from git tags:
This configuration adds setuptools_scm to your build dependencies and tells it to write the version (derived from git tags and local changes) to a _version.py file.
When you tag a commit in your git repository (e.g., git tag -a v0.2.0 -m "Version 0.2.0"), setuptools-scm will use that tag as the version. For local development with uncommitted changes, it will add development suffixes (like .dev1+g4f8e9d1.d20230315), helping you track exactly which code version is being used.
The version file is generated during the build process, so it won't exist in the source repository but will be available in the installed package:
Manually managing versions in pyproject.toml
For simpler projects, you might prefer to maintain the version directly in pyproject.toml:
However, you'll need a way for your package to access this version at runtime. One approach is to use the importlib.metadata module (available in Python 3.8+ or via the importlib-metadata backport):
This method works well for installed packages but has limitations during development. It's best suited for simple projects or when integration with development tools that expect a static version number is not needed.
Final thoughts
This article explored how pyproject.toml has transformed Python project management through centralized, declarative configuration.
The adoption of pyproject.toml by the Python ecosystem demonstrates a commitment to standardization that benefits developers with enhanced security, simplified workflows, and better dependency management.
Consider migrating your existing projects to pyproject.toml, starting simple and gradually incorporating more advanced features. Effective Python project management is an ongoing refinement process, and embracing this approach will serve you well as your projects evolve.
Happy coding!