Skip to content

Commit bb289c4

Browse files
committed
Add option to include build-system dependencies
Also fix some docstrings that were not valid rst. Include build-system hook in via comments Use meaningful names for fake build deps Install static deps before requesting dynamic deps Address review feedback Remove python 3.7 from tox.ini This should probably have been done in 51d9e1c (Drop support for Python 3.7). Use default labels for links Add test coverage for Dependency Introduce piptools.build module for building project metadata Fix naming Fixes for checkqa Address feedback Also * remove unused `src_file` argument * use filename as comes_from to avoid getting absolute paths in the output * remove unused small-fake-c from fake_dists_with_build_deps * mark tests as backtracking_resolver_only since the result is independent of resolver Use Any type because mypy gives inconsistent results Address feedback (II) Assert exit code separately from stdout Use intermediate variable Move 'may be used more than once' to end of help text Rename build-deps-only > only-build-deps Use ALL_BUILD_DISTRIBUTIONS from already imported options Use relative file path in comes from Remove xfail The test is failing locally since "Speed up tests execution (jazzband#1963)" but the xfail should not be part of the PR. Make pip-compile silent by default Improve type hints in build Remove excessive comments Give build distribution literal a meaningful name Test that transient build deps are excluded Address feedback and adapt for build>1 Fix checkqa Use build target terminology consistently Use pathlib.Path consistently in build.py and new tests More specific type hinting in build.py Don't convert to absolute path unnecessarily Update help texts * Use "extract" instead of "install" in help texts * Align texts that said different things but probably should be the same Import Monkeypatch from public API
1 parent 65b0d36 commit bb289c4

File tree

14 files changed

+751
-84
lines changed

14 files changed

+751
-84
lines changed

README.md

+66
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,70 @@ dependencies, making any newly generated `requirements.txt` environment-dependen
548548
As a general rule, it's advised that users should still always execute `pip-compile`
549549
on each targeted Python environment to avoid issues.
550550

551+
### Maximizing reproducibility
552+
553+
`pip-tools` is a great tool to improve the reproducibility of builds.
554+
But there are a few things to keep in mind.
555+
556+
- `pip-compile` will produce different results in different environments as described in the previous section.
557+
- `pip` must be used with the `PIP_CONSTRAINT` environment variable to lock dependencies in build environments as documented in [#8439](https://github.com/pypa/pip/issues/8439).
558+
- Dependencies come from many sources.
559+
560+
Continuing the `pyproject.toml` example from earlier, creating a single lock file could be done like:
561+
562+
```console
563+
$ pip-compile --all-build-deps --all-extras --output-file=constraints.txt --strip-extras pyproject.toml
564+
#
565+
# This file is autogenerated by pip-compile with Python 3.9
566+
# by the following command:
567+
#
568+
# pip-compile --all-build-deps --all-extras --output-file=constraints.txt --strip-extras pyproject.toml
569+
#
570+
asgiref==3.5.2
571+
# via django
572+
attrs==22.1.0
573+
# via pytest
574+
backports-zoneinfo==0.2.1
575+
# via django
576+
django==4.1
577+
# via my-cool-django-app (pyproject.toml)
578+
editables==0.3
579+
# via hatchling
580+
hatchling==1.11.1
581+
# via my-cool-django-app (pyproject.toml::build-system.requires)
582+
iniconfig==1.1.1
583+
# via pytest
584+
packaging==21.3
585+
# via
586+
# hatchling
587+
# pytest
588+
pathspec==0.10.2
589+
# via hatchling
590+
pluggy==1.0.0
591+
# via
592+
# hatchling
593+
# pytest
594+
py==1.11.0
595+
# via pytest
596+
pyparsing==3.0.9
597+
# via packaging
598+
pytest==7.1.2
599+
# via my-cool-django-app (pyproject.toml)
600+
sqlparse==0.4.2
601+
# via django
602+
tomli==2.0.1
603+
# via
604+
# hatchling
605+
# pytest
606+
```
607+
608+
Some build backends may also request build dependencies dynamically using the `get_requires_for_build_` hooks described in [PEP 517] and [PEP 660].
609+
This will be indicated in the output with one of the following suffixes:
610+
611+
- `(pyproject.toml::build-system.backend::editable)`
612+
- `(pyproject.toml::build-system.backend::sdist)`
613+
- `(pyproject.toml::build-system.backend::wheel)`
614+
551615
### Other useful tools
552616

553617
- [pip-compile-multi](https://pip-compile-multi.readthedocs.io/en/latest/) - pip-compile command wrapper for multiple cross-referencing requirements files.
@@ -600,5 +664,7 @@ note that it is deprecated and will be removed in a future release.
600664
[Matrix Space]: https://matrix.to/#/%23jazzband:matrix.org
601665
[pip-tools-overview]: https://github.com/jazzband/pip-tools/raw/main/img/pip-tools-overview.svg
602666
[environment-markers]: https://peps.python.org/pep-0508/#environment-markers
667+
[PEP 517]: https://peps.python.org/pep-0517/
668+
[PEP 660]: https://peps.python.org/pep-0660/
603669
[discord-chat]: https://discord.gg/pypa
604670
[discord-chat-image]: https://img.shields.io/discord/803025117553754132?label=Discord%20chat%20%23pip-tools&style=flat-square

examples/readme/constraints.txt

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#
2+
# This file is autogenerated by pip-compile with Python 3.11
3+
# by the following command:
4+
#
5+
# pip-compile --all-build-deps --all-extras --output-file=constraints.txt --strip-extras pyproject.toml
6+
#
7+
asgiref==3.5.2
8+
# via django
9+
attrs==22.1.0
10+
# via pytest
11+
django==4.1
12+
# via my-cool-django-app (pyproject.toml)
13+
editables==0.3
14+
# via hatchling
15+
hatchling==1.11.1
16+
# via my-cool-django-app (pyproject.toml::build-system.requires)
17+
iniconfig==1.1.1
18+
# via pytest
19+
packaging==21.3
20+
# via
21+
# hatchling
22+
# pytest
23+
pathspec==0.10.2
24+
# via hatchling
25+
pluggy==1.0.0
26+
# via
27+
# hatchling
28+
# pytest
29+
py==1.11.0
30+
# via pytest
31+
pyparsing==3.0.9
32+
# via packaging
33+
pytest==7.1.2
34+
# via my-cool-django-app (pyproject.toml)
35+
sqlparse==0.4.2
36+
# via django
37+
tomli==2.0.1
38+
# via
39+
# hatchling
40+
# pytest

examples/readme/pyproject.toml

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "my-cool-django-app"
7+
version = "42"
8+
dependencies = ["django"]
9+
10+
[project.optional-dependencies]
11+
dev = ["pytest"]

piptools/build.py

+174
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
from __future__ import annotations
2+
3+
import collections
4+
import contextlib
5+
import pathlib
6+
import sys
7+
import tempfile
8+
from dataclasses import dataclass
9+
from importlib import metadata as importlib_metadata
10+
from typing import Any, Iterator, Protocol, TypeVar, overload
11+
12+
import build
13+
import build.env
14+
import pyproject_hooks
15+
from pip._internal.req import InstallRequirement
16+
from pip._internal.req.constructors import install_req_from_line, parse_req_from_line
17+
18+
PYPROJECT_TOML = "pyproject.toml"
19+
20+
_T = TypeVar("_T")
21+
22+
23+
if sys.version_info >= (3, 10):
24+
from importlib.metadata import PackageMetadata
25+
else:
26+
27+
class PackageMetadata(Protocol):
28+
@overload
29+
def get_all(self, name: str, failobj: None = None) -> list[Any] | None:
30+
...
31+
32+
@overload
33+
def get_all(self, name: str, failobj: _T) -> list[Any] | _T:
34+
...
35+
36+
37+
@dataclass
38+
class ProjectMetadata:
39+
extras: tuple[str, ...]
40+
requirements: tuple[InstallRequirement, ...]
41+
build_requirements: tuple[InstallRequirement, ...]
42+
43+
44+
def build_project_metadata(
45+
src_file: pathlib.Path,
46+
build_targets: tuple[str, ...],
47+
*,
48+
isolated: bool,
49+
quiet: bool,
50+
) -> ProjectMetadata:
51+
"""
52+
Return the metadata for a project.
53+
54+
Uses the ``prepare_metadata_for_build_wheel`` hook for the wheel metadata
55+
if available, otherwise ``build_wheel``.
56+
57+
Uses the ``prepare_metadata_for_build_{target}`` hook for each ``build_targets``
58+
if available.
59+
60+
:param src_file: Project source file
61+
:param build_targets: A tuple of build targets to get the dependencies
62+
of (``sdist`` or ``wheel`` or ``editable``).
63+
:param isolated: Whether to run invoke the backend in the current
64+
environment or to create an isolated one and invoke it
65+
there.
66+
:param quiet: Whether to suppress the output of subprocesses.
67+
"""
68+
69+
src_dir = src_file.parent
70+
with _create_project_builder(src_dir, isolated=isolated, quiet=quiet) as builder:
71+
metadata = _build_project_wheel_metadata(builder)
72+
extras = tuple(metadata.get_all("Provides-Extra") or ())
73+
requirements = tuple(
74+
_prepare_requirements(metadata=metadata, src_file=src_file)
75+
)
76+
build_requirements = tuple(
77+
_prepare_build_requirements(
78+
builder=builder,
79+
src_file=src_file,
80+
build_targets=build_targets,
81+
package_name=_get_name(metadata),
82+
)
83+
)
84+
return ProjectMetadata(
85+
extras=extras,
86+
requirements=requirements,
87+
build_requirements=build_requirements,
88+
)
89+
90+
91+
@contextlib.contextmanager
92+
def _create_project_builder(
93+
src_dir: pathlib.Path, *, isolated: bool, quiet: bool
94+
) -> Iterator[build.ProjectBuilder]:
95+
if quiet:
96+
runner = pyproject_hooks.quiet_subprocess_runner
97+
else:
98+
runner = pyproject_hooks.default_subprocess_runner
99+
100+
if not isolated:
101+
yield build.ProjectBuilder(src_dir, runner=runner)
102+
return
103+
104+
with build.env.DefaultIsolatedEnv() as env:
105+
builder = build.ProjectBuilder.from_isolated_env(env, src_dir, runner)
106+
env.install(builder.build_system_requires)
107+
env.install(builder.get_requires_for_build("wheel"))
108+
yield builder
109+
110+
111+
def _build_project_wheel_metadata(
112+
builder: build.ProjectBuilder,
113+
) -> PackageMetadata:
114+
with tempfile.TemporaryDirectory() as tmpdir:
115+
path = pathlib.Path(builder.metadata_path(tmpdir))
116+
return importlib_metadata.PathDistribution(path).metadata
117+
118+
119+
def _get_name(metadata: PackageMetadata) -> str:
120+
retval = metadata.get_all("Name")[0] # type: ignore[index]
121+
assert isinstance(retval, str)
122+
return retval
123+
124+
125+
def _prepare_requirements(
126+
metadata: PackageMetadata, src_file: pathlib.Path
127+
) -> Iterator[InstallRequirement]:
128+
package_name = _get_name(metadata)
129+
comes_from = f"{package_name} ({src_file})"
130+
package_dir = src_file.parent
131+
132+
for req in metadata.get_all("Requires-Dist") or []:
133+
parts = parse_req_from_line(req, comes_from)
134+
if parts.requirement.name == package_name:
135+
# Replace package name with package directory in the requirement
136+
# string so that pip can find the package as self-referential.
137+
# Note the string can contain extras, so we need to replace only
138+
# the package name, not the whole string.
139+
replaced_package_name = req.replace(package_name, package_dir, 1)
140+
parts = parse_req_from_line(replaced_package_name, comes_from)
141+
142+
yield InstallRequirement(
143+
parts.requirement,
144+
comes_from,
145+
link=parts.link,
146+
markers=parts.markers,
147+
extras=parts.extras,
148+
)
149+
150+
151+
def _prepare_build_requirements(
152+
builder: build.ProjectBuilder,
153+
src_file: pathlib.Path,
154+
build_targets: tuple[str, ...],
155+
package_name: str,
156+
) -> Iterator[InstallRequirement]:
157+
result = collections.defaultdict(set)
158+
159+
# Build requirements will only be present if a pyproject.toml file exists,
160+
# but if there is also a setup.py file then only that will be explicitly
161+
# processed due to the order of `DEFAULT_REQUIREMENTS_FILES`.
162+
src_file = src_file.parent / PYPROJECT_TOML
163+
164+
for req in builder.build_system_requires:
165+
result[req].add(f"{package_name} ({src_file}::build-system.requires)")
166+
for build_target in build_targets:
167+
for req in builder.get_requires_for_build(build_target):
168+
result[req].add(
169+
f"{package_name} ({src_file}::build-system.backend::{build_target})"
170+
)
171+
172+
for req, comes_from_sources in result.items():
173+
for comes_from in comes_from_sources:
174+
yield install_req_from_line(req, comes_from=comes_from)

0 commit comments

Comments
 (0)