|
| 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 | +] |
0 commit comments