Skip to content

Commit da0f890

Browse files
✨ feat(runner): add PEP 723 inline script metadata support (#3912)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent b232d2d commit da0f890

File tree

9 files changed

+459
-2
lines changed

9 files changed

+459
-2
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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,46 @@ 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:** the runner uses the normal Python discovery (env name factors, ``.python-version`` files,
280+
or the host Python running tox). When ``requires-python`` is present, tox validates that the discovered Python satisfies
281+
the constraint and fails with a clear error if it does not. This works correctly with free-threaded interpreters and
282+
avoids forcing an unnecessarily old Python version.
283+
284+
**Plugin support:** the ``Pep723Mixin`` class at ``tox.tox_env.python.pep723`` contains all the PEP 723 logic
285+
independent of the venv backend. Third-party plugins (e.g. tox-uv) can compose it with their own venv implementation
286+
without duplicating code.
287+
248288
Open-ended range bounds
249289
=======================
250290

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: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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 _setup_env(self) -> None:
91+
super()._setup_env()
92+
if self._base_python_explicitly_set:
93+
msg = "cannot set base_python with virtualenv-pep-723 runner; use requires-python in the script"
94+
raise Fail(msg)
95+
if script := self.conf["script"]:
96+
tox_root: Path = self.core["tox_root"]
97+
if not (tox_root / script).is_file():
98+
msg = f"script file not found: {tox_root / script}"
99+
raise Fail(msg)
100+
metadata = self._get_script_metadata()
101+
if metadata.requires_python:
102+
info = self.base_python
103+
py_version = Version(f"{info.version_info.major}.{info.version_info.minor}.{info.version_info.micro}")
104+
if py_version not in SpecifierSet(metadata.requires_python):
105+
msg = f"python {py_version} does not satisfy requires-python {metadata.requires_python!r}"
106+
raise Fail(msg)
107+
if getattr(self.options, "skip_env_install", False):
108+
logging.warning("skip installing dependencies")
109+
return
110+
if metadata.dependencies:
111+
root: Path = self.core["tox_root"]
112+
requirements = PythonDeps(metadata.dependencies, root)
113+
self._install(requirements, type(self).__name__, "deps")
114+
115+
def _get_script_metadata(self) -> ScriptMetadata:
116+
if self._script_metadata is None:
117+
if not (script := self.conf["script"]):
118+
self._script_metadata = ScriptMetadata()
119+
return self._script_metadata
120+
tox_root: Path = self.core["tox_root"]
121+
full_path = tox_root / script
122+
if not full_path.is_file():
123+
self._script_metadata = ScriptMetadata()
124+
return self._script_metadata
125+
self._script_metadata = _parse_script_metadata(full_path.read_text(encoding="utf-8"))
126+
return self._script_metadata
127+
128+
129+
def _parse_script_metadata(script: str) -> ScriptMetadata:
130+
blocks = [(m.group("type"), m.group("content")) for m in _SCRIPT_METADATA_RE.finditer(script)]
131+
script_blocks = [(t, c) for t, c in blocks if t == "script"]
132+
if len(script_blocks) > 1:
133+
msg = "multiple [script] metadata blocks found in script"
134+
raise ValueError(msg)
135+
if not script_blocks:
136+
return ScriptMetadata()
137+
content = script_blocks[0][1]
138+
stripped = "".join(
139+
line[2:] if len(line) > 1 and line[1] == " " else line[1:] for line in content.splitlines(keepends=True)
140+
)
141+
metadata = tomllib.loads(stripped)
142+
return ScriptMetadata(
143+
requires_python=metadata.get("requires-python"),
144+
dependencies=metadata.get("dependencies", []),
145+
)
146+
147+
148+
__all__ = [
149+
"Pep723Mixin",
150+
"ScriptMetadata",
151+
]
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+
]

0 commit comments

Comments
 (0)