Skip to content

Commit 82629e5

Browse files
authored
✨ feat(config): add package = "deps-only" mode (#3774)
Many tox environments -- coverage combining, doc building, linting -- need a project's dependencies (and extras) but not the project itself. Today the only option is `skip_install = true` plus manually duplicating every dependency in `deps`, which drifts out of sync with `pyproject.toml`. This adds `package = "deps-only"` so those environments can declare `extras = ["docs"]` and get the right dependencies automatically, just like a normal test environment, without paying for a build step. 🎯 The implementation uses a two-tier resolution strategy. It first tries to read dependencies statically from a PEP 621 `[project]` table in `pyproject.toml` -- no packaging environment needed, same fast path as `dependency_groups`. If that fails (dynamic deps, no `[project]` table, `setup.cfg`-only projects), it falls back to the existing `.pkg` env and `prepare_metadata_for_build_wheel` to extract metadata. A new `load_deps_for_env` abstract method on `PythonPackageToxEnv` exposes this fallback cleanly to the runner. The `extras` config key works identically to other package modes, including validation of unknown extra names. Existing configurations are unaffected -- `deps-only` is purely opt-in alongside the existing `wheel`, `sdist`, `sdist-wheel`, `editable`, `editable-legacy`, `skip`, and `external` modes. Fixes #2301 --------- Signed-off-by: Bernát Gábor <bgabor8@bloomberg.net>
1 parent 95d63fb commit 82629e5

21 files changed

Lines changed: 443 additions & 19 deletions

File tree

docs/changelog/2301.feature.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Add ``package = "deps-only"`` mode that installs the package's dependencies (including extras) without building or
2+
installing the package itself. For projects with static :PEP:`621` metadata, dependencies are read directly from
3+
``pyproject.toml`` without creating a packaging environment - by :user:`gaborbernat`.

docs/changelog/3774.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix PEP 517 backend crash detection to handle both ``ENOENT`` (missing interpreter) and runtime crashes without hanging,
2+
and fix flaky ``test_provision_install_pkg_pep517`` integration test by using a pre-built wheel instead of an sdist to
3+
avoid devpi mirror dependency on setuptools - by :user:`gaborjbernat`.

docs/how-to/usage.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,46 @@ environments via the :ref:`extras` configuration:
516516
This installs your package together with the specified extras, avoiding the need to duplicate dependency lists in both
517517
``pyproject.toml`` and your tox configuration.
518518

519+
************************************
520+
Install extras without the package
521+
************************************
522+
523+
Sometimes you need the package's dependencies (including extras) without installing the package itself. For example,
524+
coverage combining, documentation builds, or linting environments that share the same dependency set. Use ``package =
525+
"deps-only"`` instead of ``skip_install = true`` combined with manually duplicated ``deps``:
526+
527+
.. tab:: TOML
528+
529+
.. code-block:: toml
530+
531+
# pyproject.toml
532+
[project]
533+
name = "myproject"
534+
dependencies = ["httpx>=0.27"]
535+
536+
[project.optional-dependencies]
537+
docs = ["sphinx>=7", "furo"]
538+
539+
.. code-block:: toml
540+
541+
# tox.toml
542+
[env.docs]
543+
package = "deps-only"
544+
extras = ["docs"]
545+
commands = [["sphinx-build", "-W", "docs", "docs/_build/html"]]
546+
547+
.. tab:: INI
548+
549+
.. code-block:: ini
550+
551+
[testenv:docs]
552+
package = deps-only
553+
extras = docs
554+
commands = sphinx-build -W docs docs/_build/html
555+
556+
This reads your ``pyproject.toml`` directly (no build step) and installs ``httpx``, ``sphinx``, and ``furo`` into the
557+
environment. If your dependencies are dynamic, tox falls back to using the packaging environment to extract metadata.
558+
519559
.. ------------------------------------------------------------------------------------------
520560
521561
.. Environment Customization

docs/reference/config.rst

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,9 +1400,16 @@ Python run
14001400
:keys: package
14011401
:version_added: 4.0
14021402

1403-
When option can be one of ``wheel``, ``sdist``, ``sdist-wheel``, ``editable``, ``editable-legacy``, ``skip``, or
1404-
``external``. If :ref:`use_develop` is set this becomes a constant of ``editable``. If :ref:`skip_install` is set
1405-
this becomes a constant of ``skip``.
1403+
When option can be one of ``wheel``, ``sdist``, ``sdist-wheel``, ``editable``, ``editable-legacy``, ``deps-only``,
1404+
``skip``, or ``external``. If :ref:`use_develop` is set this becomes a constant of ``editable``. If
1405+
:ref:`skip_install` is set this becomes a constant of ``skip``.
1406+
1407+
When ``deps-only`` is selected, tox installs the package's dependencies (including any requested :ref:`extras`) but
1408+
does **not** build or install the package itself. This is useful for environments that need the same dependencies as
1409+
the package without the package, such as coverage combining, documentation building, or linting. For projects with
1410+
static :pep:`621` metadata in ``pyproject.toml``, dependencies are read directly without creating a packaging
1411+
environment. For dynamic dependencies or non-PEP-621 projects, the packaging environment is used to extract
1412+
metadata.
14061413

14071414
When ``sdist-wheel`` is selected, tox first builds a source distribution and then builds a wheel from that sdist
14081415
(rather than directly from the source tree). This is useful for verifying that the sdist is complete and that the

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ test = [
9696
"pytest-timeout>=2.4",
9797
"pytest-xdist>=3.8",
9898
"re-assert>=1.1",
99-
"setuptools>=80.10.2",
99+
"setuptools>=80.10.2,<82",
100100
"time-machine>=3.2; implementation_name!='pypy'",
101101
"wheel>=0.46.3",
102102
]

src/tox/execute/pep517_backend.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,12 @@ def local_execute(self, options: ExecuteOptions) -> tuple[LocalSubProcessExecute
5757
)
5858
status = instance.__enter__() # noqa: PLC2801
5959
self._local_execute = instance, status
60+
process_exited = instance.process is None # Popen failed (e.g. ENOENT)
6061
while True:
6162
if b"started backend " in status.out:
6263
self.is_alive = True
6364
break
64-
if b"failed to start backend" in status.err:
65+
if b"failed to start backend" in status.err or process_exited:
6566
from tox.tox_env.python.virtual_env.package.pyproject import ToxBackendFailed # noqa: PLC0415
6667

6768
failure = BackendFailed(
@@ -75,6 +76,8 @@ def local_execute(self, options: ExecuteOptions) -> tuple[LocalSubProcessExecute
7576
)
7677
self._exc = ToxBackendFailed(failure)
7778
raise self._exc
79+
if instance.process is not None and instance.process.poll() is not None:
80+
process_exited = True # give reader threads one more iteration to drain
7881
time.sleep(0.01) # wait a short while for the output to populate
7982
return self._local_execute
8083

src/tox/pytest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def __init__( # noqa: PLR0913
144144
@staticmethod
145145
def _setup_files(dest: Path, base: Path | None, content: dict[str, Any]) -> None:
146146
if base is not None:
147-
shutil.copytree(str(base), str(dest))
147+
shutil.copytree(str(base), str(dest), ignore=shutil.ignore_patterns(".tox"))
148148
dest.mkdir(exist_ok=True)
149149
for key, value in content.items():
150150
if not isinstance(key, str):

src/tox/tox.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@
352352
},
353353
"package": {
354354
"type": "string",
355-
"description": "package installation mode - wheel | sdist | sdist-wheel | editable | editable-legacy | skip | external "
355+
"description": "package installation mode - wheel | sdist | sdist-wheel | editable | editable-legacy | deps-only | skip | external "
356356
},
357357
"extras": {
358358
"type": "array",

src/tox/tox_env/python/extras.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import TYPE_CHECKING
5+
6+
from packaging.requirements import Requirement
7+
8+
from .virtual_env.package.util import dependencies_with_extras_from_markers
9+
10+
if TYPE_CHECKING:
11+
from pathlib import Path
12+
13+
if sys.version_info >= (3, 11): # pragma: no cover (py311+)
14+
import tomllib
15+
else: # pragma: no cover (py311+)
16+
import tomli as tomllib
17+
18+
19+
def resolve_extras_static(root: Path, extras: set[str]) -> list[Requirement] | None:
20+
pyproject_file = root / "pyproject.toml"
21+
if not pyproject_file.exists():
22+
return None
23+
with pyproject_file.open("rb") as file_handler:
24+
pyproject = tomllib.load(file_handler)
25+
if "project" not in pyproject:
26+
return None
27+
project = pyproject["project"]
28+
for dynamic in project.get("dynamic", []):
29+
if dynamic == "dependencies" or (extras and dynamic == "optional-dependencies"):
30+
return None
31+
deps_with_markers: list[tuple[Requirement, set[str | None]]] = [
32+
(Requirement(i), {None}) for i in project.get("dependencies", [])
33+
]
34+
optional_deps = project.get("optional-dependencies", {})
35+
for extra, reqs in optional_deps.items():
36+
deps_with_markers.extend((Requirement(req), {extra}) for req in (reqs or []))
37+
return dependencies_with_extras_from_markers(
38+
deps_with_markers=deps_with_markers,
39+
extras=extras,
40+
package_name=project.get("name", "."),
41+
available_extras=set(optional_deps.keys()),
42+
)
43+
44+
45+
__all__ = [
46+
"resolve_extras_static",
47+
]

src/tox/tox_env/python/package.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ def _setup_env(self) -> None:
6565
def requires(self) -> tuple[Requirement, ...] | PythonDeps:
6666
raise NotImplementedError
6767

68+
@abstractmethod
69+
def load_deps_for_env(self, for_env: EnvConfigSet) -> list[Requirement]:
70+
raise NotImplementedError
71+
6872
def register_run_env(self, run_env: RunToxEnv) -> Generator[tuple[str, str], PackageToxEnv, None]:
6973
yield from super().register_run_env(run_env)
7074
if run_env.conf["package"] != "skip" and "deps" not in self.conf:

0 commit comments

Comments
 (0)