TATECHATLAS
◎ English
Programming / Tip

Python: Store verifiable dependency description and build environment details as separate parts of a reproducible release contract

Store runtime dependencies in [project.dependencies] and build requirements in [build-system.requires] of pyproject.toml, then pin exact versions with sha256 hashes in a separate requirements file to create a byte-level verifiable release contract.

On this page

Define runtime dependencies in [project.dependencies] and build-time requirements in [build-system.requires] inside pyproject.toml. Pin exact versions and add --hash=sha256 entries in a separate requirements file to create a verifiable, byte-level reproducible release contract.

Separate runtime dependencies from build-system requirements in pyproject.toml

The pyproject.toml specification defines two distinct dependency layers. The [build-system] table declares the Python-level dependencies needed to run the project's build system, such as setuptools or flit. The requires key is mandatory for that table and lists build-time packages. The [project] table, introduced by PEP 621, contains the dependencies key, which lists the packages that consumers need at install time (runtime dependencies). These are mapped to Requires-Dist entries in the package metadata.

Keeping these layers separate is essential for a reproducible release contract. The build environment must be reproducible to produce the same distribution artifacts, while the runtime environment must be reproducible to ensure consistent behavior after installation. By declaring both in pyproject.toml, you make the contract explicit and verifiable: anyone can inspect which tools are needed to build and which libraries are needed to run. The specification notes that tools should not require the [build-system] table to exist; if absent, defaults are used, which can vary. Therefore, explicitly stating both layers removes ambiguity.

Why pinning and hash-checking address different trust boundaries

Pinning package versions with == in a requirements file protects against upstream version changes that could introduce bugs or incompatibilities. However, it still trusts the package index (like PyPI) and the TLS certificate chain. An attacker who compromises the index or performs a man-in-the-middle attack could serve a malicious package with the same version number.

Hash-checking mode, enabled with --require-hashes and --hash=sha256:... entries, verifies the downloaded artifact byte-for-byte against a known digest. This guards against index compromise, certificate chain attacks, and silent re-uploads of a package without a version bump (on indexes that allow it). The pip documentation states that hash-checking is a labour-saving alternative to running a private index server: it removes the need to upload packages, maintain ACLs, and keep an audit trail, while a VCS provides that trail for the requirements file. The trade-off is maintenance cost: every time a dependency version changes, the hashes must be regenerated and reviewed. This approach is well-suited for automated server deployments where the environment is controlled.

Concrete example: a release contract with both layers

A minimal pyproject.toml declares build-system.requires (e.g., setuptools and wheel) and project.dependencies (e.g., requests and pydantic). A companion requirements-pinned.txt file, generated by pip-compile or pip freeze, pins every transitive dependency with exact versions and sha256 hashes. The two files together form the verifiable contract.

To install, run pip install --require-hashes -r requirements-pinned.txt. This enforces that every downloaded package matches the recorded hash. The build system requirements are used when building the project from source; if only a pre-built wheel is installed, they are not needed at install time, but they are still part of the contract for anyone who needs to rebuild.

The build-system.requires list ensures the build tooling is reproducible; project.dependencies declares what the installed application needs. The companion requirements file pins exact versions and provides hashes for all transitive dependencies, forming the verifiable release contract when used together with pip install --require-hashes -r requirements-pinned.txt. The hashes shown are truncated placeholders; real hashes must be generated with pip-compile or pip freeze and then manually verified or committed to version control. The separation of concerns means that updating a runtime dependency does not require changing the build system specification, and vice versa. The pyproject.toml alone does not enforce reproducibility; it is the combination with the pinned, hashed requirements file that creates the contract. The build-system table is mandatory for reproducibility because it pins the build tools; omitting it would rely on tool-specific defaults that may differ between environments, breaking the contract.

# pyproject.toml
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "myapp"
version = "1.0.0"
dependencies = ["requests>=2.31", "pydantic>=2.5"]

# requirements-pinned.txt (generated by pip-compile or pip freeze)
requests==2.31.0 --hash=sha256:58cd2187c8b806c9970b3f8a8e7c1d2e4f6a8b9c0d1e2f3a4b5c6d7e8f9a0b1
pydantic==2.5.0 --hash=sha256:7f4f1b1b0d8d4e8c9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2
urllib3==2.1.0 --hash=sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
annotated-types==0.6.0 --hash=sha256:b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3
pydantic-core==2.14.1 --hash=sha256:c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4
typing-extensions==4.9.0 --hash=sha256:d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5

# Install command enforcing hash verification:
pip install --require-hashes -r requirements-pinned.txt

Limits of this approach

Wheelhouse bundles containing compiled wheels are OS and architecture specific, so they are not portable across machines. Hash-checking does not guarantee package availability the way a private index or vendored library would; if a package is removed from the index, the install fails even if the hash is known. The [build-system] table is optional; when absent, tools fall back to defaults, which may differ across environments, breaking reproducibility. Pinning with hashes still trusts the initial resolution and does not protect against malicious code inside the pinned version itself. Finally, maintaining hashes adds overhead: every dependency update requires regenerating and reviewing the hash entries.

Applicability

  • Does your project use pyproject.toml with both [build-system] and [project] tables?
  • Are you willing to regenerate hash entries whenever a dependency version changes?
  • Is the target environment homogeneous enough that compiled wheels (if any) are portable, or can you rebuild them on each machine?
  • Do you have a process to generate and review the pinned requirements file (e.g., pip-compile or pip freeze)?
  • Can you accept the maintenance cost of updating hashes for security patches and upgrades?
  • Is the absence of a private index or vendored library acceptable for your availability requirements?

Wheelhouse bundles containing compiled wheels are OS and architecture specific, so they are not portable across machines. Hash-checking does not guarantee package availability the way a private index or vendored library would. The [build-system] table is optional; when absent, tools fall back to defaults, which may differ across environments. Pinning with hashes still trusts the initial resolution and does not protect against malicious code inside the pinned version itself.

Sources

  1. pip: repeatable installs ↗
  2. Python Packaging: pyproject.toml specification ↗
Back to top ↑