Skip to content

Commit 42e4de4

Browse files
committed
✨ feat(runner): add PEP 723 inline script metadata support
PEP 723 lets scripts declare dependencies and Python version inline. The new virtualenv-pep-723 runner reads this metadata so environments need only a runner and script key — no deps/base_python duplication. The shared logic lives in Pep723Mixin (tox.tox_env.python.pep723) so third-party plugins like tox-uv can compose it with their own venv backend without duplicating code. The concrete runner composes the mixin with VirtualEnv and RunToxEnv, skipping PythonRun entirely so config keys like deps/extras/pylock are structurally absent. Closes #3897
1 parent b232d2d commit 42e4de4

File tree

11 files changed

+459
-4
lines changed

11 files changed

+459
-4
lines changed

docs/changelog/3897.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add ``virtualenv-pep-723`` runner that reads dependencies and Python version from :PEP:`723` inline script metadata — no
2+
need to duplicate them in tox config - by :user:`gaborbernat`.

docs/explanation.rst

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,45 @@ invocation. No configuration changes will be needed.
245245
**Change detection** works the same as for ``deps``: tox caches the resolved requirements. When packages are added, only
246246
the new ones are installed. When packages are removed, the environment is recreated.
247247

248+
.. _pep723-explanation:
249+
250+
Inline script metadata (PEP 723)
251+
================================
252+
253+
.. versionadded:: 4.52
254+
255+
:PEP:`723` lets Python scripts declare their dependencies and required Python version directly in comments at the top of
256+
the file:
257+
258+
.. code-block:: python
259+
260+
# /// script
261+
# requires-python = ">=3.12"
262+
# dependencies = ["requests>=2.31", "rich"]
263+
# ///
264+
265+
import requests
266+
from rich import print
267+
268+
print(requests.get("https://httpbin.org/get").json())
269+
270+
The ``virtualenv-pep-723`` runner reads this metadata so that a tox environment needs only a ``runner`` and ``script``
271+
key — no ``deps`` or ``base_python`` duplication. Both ``requires-python`` and ``dependencies`` are optional per the PEP
272+
723 spec; omitting ``requires-python`` uses the host Python, and omitting ``dependencies`` installs nothing.
273+
274+
**How it differs from the default runner:** the ``virtualenv-pep-723`` runner skips ``PythonRun`` in the class
275+
hierarchy. Config keys like ``deps``, ``extras``, ``dependency_groups``, and ``pylock`` are structurally absent — they
276+
do not exist for this runner. Packaging is unconditionally disabled since scripts are not packages. Setting
277+
``base_python`` explicitly is rejected to prevent conflicts with the script's ``requires-python`` specifier.
278+
279+
**Python version resolution:** when ``requires-python`` contains a lower-bound specifier (``>=``, ``~=``, or ``==``),
280+
tox extracts the minimum version and passes it to virtualenv as the ``base_python``. For example, ``requires-python =
281+
">=3.12"`` resolves to ``python3.12``. Upper-bound-only specifiers like ``<4`` fall back to the host Python.
282+
283+
**Plugin support:** the ``Pep723Mixin`` class at ``tox.tox_env.python.pep723`` contains all the PEP 723 logic
284+
independent of the venv backend. Third-party plugins (e.g. tox-uv) can compose it with their own venv implementation
285+
without duplicating code.
286+
248287
Open-ended range bounds
249288
=======================
250289

docs/how-to/usage.rst

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,70 @@ subcommand, use the ``run`` subcommand explicitly:
607607
608608
.. ------------------------------------------------------------------------------------------
609609
610+
.. _howto_pep723:
611+
612+
**********************
613+
Run a PEP 723 script
614+
**********************
615+
616+
.. versionadded:: 4.52
617+
618+
If you have a standalone Python script with :PEP:`723` inline metadata:
619+
620+
.. code-block:: python
621+
622+
# /// script
623+
# requires-python = ">=3.12"
624+
# dependencies = ["requests>=2.31", "rich"]
625+
# ///
626+
627+
import requests
628+
from rich import print
629+
630+
print(requests.get("https://httpbin.org/get").json())
631+
632+
You can run it through tox without duplicating the dependency list:
633+
634+
.. tab:: TOML
635+
636+
.. code-block:: toml
637+
638+
[env.fetch]
639+
runner = "virtualenv-pep-723"
640+
script = "tools/fetch.py"
641+
642+
.. tab:: INI
643+
644+
.. code-block:: ini
645+
646+
[testenv:fetch]
647+
runner = virtualenv-pep-723
648+
script = tools/fetch.py
649+
650+
Run it with ``tox r -e fetch``. Positional arguments are forwarded: ``tox r -e fetch -- --verbose``.
651+
652+
To override the default command (which runs the script), set ``commands`` as usual:
653+
654+
.. tab:: TOML
655+
656+
.. code-block:: toml
657+
658+
[env.fetch]
659+
runner = "virtualenv-pep-723"
660+
script = "tools/fetch.py"
661+
commands = [["python", "-m", "pytest", "tests/"]]
662+
663+
.. tab:: INI
664+
665+
.. code-block:: ini
666+
667+
[testenv:fetch]
668+
runner = virtualenv-pep-723
669+
script = tools/fetch.py
670+
commands = python -m pytest tests/
671+
672+
See :ref:`pep723-explanation` for how Python version resolution and dependency installation work.
673+
610674
.. _faq_custom_pypi_server:
611675

612676
.. _howto_custom_pypi_server:

docs/reference/config.rst

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1102,7 +1102,33 @@ Run
11021102
:version_added: 4.0.0
11031103

11041104
The tox execute used to evaluate this environment. Defaults to Python virtual environments, however may be
1105-
overwritten by plugins.
1105+
overwritten by plugins. Set to ``virtualenv-pep-723`` to use inline script metadata (see :ref:`script`).
1106+
1107+
.. conf::
1108+
:keys: script
1109+
:default: <empty string>
1110+
:version_added: 4.52
1111+
1112+
Path to a Python script with :PEP:`723` inline metadata, relative to :ref:`tox_root`. Only available when
1113+
``runner = virtualenv-pep-723``. The script's ``requires-python`` and ``dependencies`` fields drive the environment's
1114+
Python version and installed packages. ``commands`` defaults to running the script with ``{posargs}`` forwarded, but
1115+
can be overridden. See :ref:`pep723-explanation` for details.
1116+
1117+
.. tab:: TOML
1118+
1119+
.. code-block:: toml
1120+
1121+
[env.check]
1122+
runner = "virtualenv-pep-723"
1123+
script = "tools/check.py"
1124+
1125+
.. tab:: INI
1126+
1127+
.. code-block:: ini
1128+
1129+
[testenv:check]
1130+
runner = virtualenv-pep-723
1131+
script = tools/check.py
11061132
11071133
.. conf::
11081134
:keys: description

docs/tutorial/getting-started.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,3 +332,4 @@ Now that you have a working tox setup, explore these topics:
332332
with :ref:`virtualenv_spec`)
333333
- :ref:`configuration` -- full configuration reference
334334
- :ref:`cli` -- complete CLI reference
335+
- :ref:`howto_pep723` -- run standalone scripts with inline dependencies (:PEP:`723`)

src/tox/plugin/manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from tox.config.loader import api as loader_api
1313
from tox.session.cmd.run import parallel, sequential
1414
from tox.tox_env import package as package_api
15-
from tox.tox_env.python.virtual_env import runner
15+
from tox.tox_env.python.virtual_env import pep723_runner, runner
1616
from tox.tox_env.python.virtual_env.package import cmd_builder, pyproject
1717
from tox.tox_env.register import REGISTER, ToxEnvRegister
1818

@@ -59,6 +59,7 @@ def _register_plugins(self, inline: ModuleType | None) -> None:
5959
internal_plugins = (
6060
loader_api,
6161
provision,
62+
pep723_runner,
6263
runner,
6364
pyproject,
6465
cmd_builder,

src/tox/tox_env/python/pep723.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""PEP 723 inline script metadata support — shared logic for any venv backend."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import re
7+
import sys
8+
from dataclasses import dataclass, field
9+
from typing import TYPE_CHECKING, Final, cast
10+
11+
from packaging.specifiers import SpecifierSet
12+
from packaging.version import Version
13+
14+
from tox.config.types import Command
15+
from tox.tox_env.errors import Fail
16+
from tox.tox_env.python.pip.req_file import PythonDeps
17+
from tox.tox_env.python.runner import add_skip_missing_interpreters_to_core, add_skip_missing_interpreters_to_env
18+
from tox.tox_env.runner import RunToxEnv
19+
20+
from .api import Python
21+
22+
if sys.version_info >= (3, 11): # pragma: >=3.11 cover
23+
import tomllib
24+
else: # pragma: <3.11 cover
25+
import tomli as tomllib
26+
27+
if TYPE_CHECKING:
28+
from pathlib import Path
29+
30+
from tox.config.main import Config
31+
from tox.config.of_type import ConfigDynamicDefinition
32+
from tox.tox_env.api import ToxEnvCreateArgs
33+
34+
_SCRIPT_METADATA_RE: Final = re.compile(
35+
r"""
36+
(?m)
37+
^[#][ ]///[ ](?P<type>[a-zA-Z0-9-]+)$ # opening: # /// <type>
38+
\s # blank line or whitespace
39+
(?P<content> # TOML content lines:
40+
(?:^[#](?:| .*)$\s)+ # each line starts with # (optionally followed by space + text)
41+
)
42+
^[#][ ]///$ # closing: # ///
43+
""",
44+
re.VERBOSE,
45+
)
46+
47+
48+
@dataclass(frozen=True)
49+
class ScriptMetadata:
50+
requires_python: str | None = None
51+
dependencies: list[str] = field(default_factory=list)
52+
53+
54+
class Pep723Mixin(Python, RunToxEnv):
55+
"""Mixin providing PEP 723 script metadata support for any venv-backed runner.
56+
57+
Concrete runners compose this with a venv backend (VirtualEnv, UvVenv, etc.) and RunToxEnv.
58+
59+
"""
60+
61+
_script_metadata: ScriptMetadata | None
62+
63+
def __init__(self, create_args: ToxEnvCreateArgs) -> None:
64+
self._script_metadata = None
65+
super().__init__(create_args)
66+
67+
def register_config(self) -> None:
68+
super().register_config()
69+
self.conf.add_config(
70+
keys=["script"],
71+
of_type=str,
72+
default="",
73+
desc="path to Python script with PEP 723 inline metadata (relative to tox_root)",
74+
)
75+
76+
def default_commands(conf: Config, env_name: str | None) -> list[Command]: # noqa: ARG001
77+
if script := self.conf["script"]:
78+
tox_root: Path = self.core["tox_root"]
79+
args = ["python", str(tox_root / script)]
80+
if (pos_args := conf.pos_args(None)) is not None:
81+
args.extend(pos_args)
82+
return [Command(args)]
83+
return []
84+
85+
commands_def = cast("ConfigDynamicDefinition[list[Command]]", self.conf._defined["commands"]) # noqa: SLF001
86+
commands_def.default = default_commands
87+
add_skip_missing_interpreters_to_core(self.core, self.options)
88+
add_skip_missing_interpreters_to_env(self.conf, self.core, self.options)
89+
90+
def _base_python_default(self, conf: Config, env_name: str | None) -> list[str]:
91+
self._base_python_explicitly_set = False
92+
metadata = self._get_script_metadata()
93+
if metadata.requires_python and (python := _min_python_from_requires(metadata.requires_python)):
94+
return [python]
95+
return super()._base_python_default(conf, env_name)
96+
97+
def _setup_env(self) -> None:
98+
super()._setup_env()
99+
if self._base_python_explicitly_set:
100+
msg = "cannot set base_python with virtualenv-pep-723 runner; use requires-python in the script"
101+
raise Fail(msg)
102+
if script := self.conf["script"]:
103+
tox_root: Path = self.core["tox_root"]
104+
if not (tox_root / script).is_file():
105+
msg = f"script file not found: {tox_root / script}"
106+
raise Fail(msg)
107+
if getattr(self.options, "skip_env_install", False):
108+
logging.warning("skip installing dependencies")
109+
return
110+
metadata = self._get_script_metadata()
111+
if metadata.dependencies:
112+
root: Path = self.core["tox_root"]
113+
requirements = PythonDeps(metadata.dependencies, root)
114+
self._install(requirements, type(self).__name__, "deps")
115+
116+
def _get_script_metadata(self) -> ScriptMetadata:
117+
if self._script_metadata is None:
118+
if not (script := self.conf["script"]):
119+
self._script_metadata = ScriptMetadata()
120+
return self._script_metadata
121+
tox_root: Path = self.core["tox_root"]
122+
full_path = tox_root / script
123+
if not full_path.is_file():
124+
self._script_metadata = ScriptMetadata()
125+
return self._script_metadata
126+
self._script_metadata = _parse_script_metadata(full_path.read_text(encoding="utf-8"))
127+
return self._script_metadata
128+
129+
130+
def _parse_script_metadata(script: str) -> ScriptMetadata:
131+
blocks = [(m.group("type"), m.group("content")) for m in _SCRIPT_METADATA_RE.finditer(script)]
132+
script_blocks = [(t, c) for t, c in blocks if t == "script"]
133+
if len(script_blocks) > 1:
134+
msg = "multiple [script] metadata blocks found in script"
135+
raise ValueError(msg)
136+
if not script_blocks:
137+
return ScriptMetadata()
138+
content = script_blocks[0][1]
139+
stripped = "".join(
140+
line[2:] if len(line) > 1 and line[1] == " " else line[1:] for line in content.splitlines(keepends=True)
141+
)
142+
metadata = tomllib.loads(stripped)
143+
return ScriptMetadata(
144+
requires_python=metadata.get("requires-python"),
145+
dependencies=metadata.get("dependencies", []),
146+
)
147+
148+
149+
def _min_python_from_requires(requires_python: str) -> str | None:
150+
for spec in SpecifierSet(requires_python):
151+
if spec.operator in {">=", "~=", "=="}:
152+
version = Version(spec.version.replace(".*", ""))
153+
return f"python{version.major}.{version.minor}"
154+
return None
155+
156+
157+
__all__ = [
158+
"Pep723Mixin",
159+
"ScriptMetadata",
160+
]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Concrete virtualenv-backed PEP 723 runner."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
from tox.plugin import impl
8+
from tox.tox_env.python.pep723 import Pep723Mixin
9+
from tox.tox_env.runner import RunToxEnv
10+
11+
from .api import VirtualEnv
12+
13+
if TYPE_CHECKING:
14+
from tox.tox_env.package import Package
15+
from tox.tox_env.register import ToxEnvRegister
16+
17+
18+
class Pep723Runner(Pep723Mixin, VirtualEnv, RunToxEnv):
19+
@staticmethod
20+
def id() -> str:
21+
return "virtualenv-pep-723"
22+
23+
def _register_package_conf(self) -> bool: # noqa: PLR6301
24+
return False
25+
26+
@property
27+
def _package_tox_env_type(self) -> str:
28+
raise NotImplementedError
29+
30+
@property
31+
def _external_pkg_tox_env_type(self) -> str:
32+
raise NotImplementedError
33+
34+
def _build_packages(self) -> list[Package]: # noqa: PLR6301
35+
return []
36+
37+
38+
@impl
39+
def tox_register_tox_env(register: ToxEnvRegister) -> None:
40+
register.add_run_env(Pep723Runner)
41+
42+
43+
__all__ = [
44+
"Pep723Runner",
45+
]

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
if TYPE_CHECKING:
2222
from collections.abc import Callable, Iterator, Sequence
2323

24-
from build import DistributionType
2524
from pytest_mock import MockerFixture
2625

26+
from build import DistributionType
2727
from tox.config.loader.api import Override
2828

2929
pytest_plugins = "tox.pytest"

0 commit comments

Comments
 (0)