Skip to content

Commit 55c4e93

Browse files
gilgamezhclaude
andcommitted
Add PEP 723 inline script metadata support
Closes #423. fades now understands the PEP 723 `# /// script` metadata block, so it can run scripts written for other runners (pipx, pip-run, uv) and vice versa. - parse the block's `dependencies` and merge them like any other source - honor `requires-python`: keep the selected interpreter if it satisfies the specifier, otherwise auto-discover a suitable one on PATH (failing cleanly if none is available); an explicit --python that conflicts is reported instead of being silently overridden - malformed metadata (bad TOML, bad requirement, bad/non-string requires-python, non-list dependencies, multiple script blocks) raises a clean FadesError with an explanatory log line instead of dumping a traceback - use stdlib tomllib on 3.11+, falling back to tomli on older Pythons Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 873110b commit 55c4e93

12 files changed

Lines changed: 559 additions & 11 deletions

File tree

README.rst

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ In case of multiple definitions of the same dependency, command line
173173
overrides everything else, and requirements file overrides what is
174174
specified in the source code.
175175

176-
Finally, you can include package names in the script docstring, after
176+
You can also include package names in the script docstring, after
177177
a line where "fades" is written, until the end of the docstring;
178178
for example::
179179

@@ -186,6 +186,29 @@ for example::
186186
otherpackage
187187
"""
188188

189+
Finally, *fades* understands the inline script metadata defined by
190+
`PEP 723 <https://peps.python.org/pep-0723/>`_, so it can run scripts written
191+
for other runners (like ``pipx`` or ``pip-run``) and vice versa. Just add a
192+
``# /// script`` block at the top of the script::
193+
194+
# /// script
195+
# requires-python = ">=3.11"
196+
# dependencies = [
197+
# "requests<3",
198+
# "rich",
199+
# ]
200+
# ///
201+
202+
import requests
203+
from rich.pretty import pprint
204+
205+
The ``dependencies`` are installed like any other dependency. If
206+
``requires-python`` is present, *fades* will use it to pick a suitable Python
207+
interpreter: if the one in use (the default, or the one given with
208+
``--python``) does not satisfy the requirement, *fades* will try to find an
209+
appropriate interpreter in the system, and fail if none is available (in that
210+
case, adjust the script's ``requires-python`` or install a matching Python).
211+
189212

190213
About different repositories
191214
----------------------------

fades/helpers.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""A collection of utilities for fades."""
1818

1919
import json
20+
import shutil
2021
import logging
2122
import os
2223
import subprocess
@@ -28,12 +29,17 @@
2829
from urllib.error import HTTPError
2930

3031
from packaging.requirements import Requirement
32+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
3133
from packaging.version import Version
3234

3335
from fades import FadesError, _version
3436

3537
logger = logging.getLogger(__name__)
3638

39+
# range of CPython 3 minor versions probed when auto-selecting an interpreter to
40+
# satisfy a PEP 723 'requires-python' specifier
41+
PYTHON_MINOR_RANGE = range(6, 30)
42+
3743
# command to retrieve the version from an external Python
3844
SHOW_VERSION_CMD = """
3945
import sys, json
@@ -162,6 +168,93 @@ def get_interpreter_version(requested_interpreter):
162168
return (requested_interpreter, is_current)
163169

164170

171+
def _get_interpreter_full_version(interpreter=None):
172+
"""Return the (major, minor, micro) version tuple of an interpreter."""
173+
if interpreter is None:
174+
return tuple(sys.version_info[:3])
175+
args = [interpreter, '-c', SHOW_VERSION_CMD]
176+
try:
177+
raw = logged_exec(args)
178+
# parse inside the try: a noisy interpreter (e.g. a shim printing to stderr, which
179+
# logged_exec merges into stdout) can make raw[0] not be the expected JSON; turning
180+
# any such failure into a FadesError lets _find_interpreter skip that candidate
181+
info = json.loads(raw[0])
182+
return (info['major'], info['minor'], info['micro'])
183+
except Exception as error:
184+
logger.error("Error getting requested interpreter version: %s", error)
185+
raise FadesError("Could not get interpreter version")
186+
187+
188+
def _find_interpreter(specifier):
189+
"""Search PATH for a python interpreter whose version satisfies the specifier."""
190+
candidate_names = ["python3.{}".format(minor) for minor in PYTHON_MINOR_RANGE]
191+
candidate_names += ["python3", "python"]
192+
193+
found = {} # path -> Version, to avoid probing the same interpreter twice
194+
for name in candidate_names:
195+
path = shutil.which(name)
196+
if path is None or path in found:
197+
continue
198+
try:
199+
major, minor, micro = _get_interpreter_full_version(path)
200+
except FadesError:
201+
continue
202+
found[path] = Version("{}.{}.{}".format(major, minor, micro))
203+
204+
candidates = sorted(
205+
(version, path) for path, version in found.items()
206+
if specifier.contains(version, prereleases=True))
207+
if not candidates:
208+
return None
209+
# pick the highest satisfying version
210+
return candidates[-1][1]
211+
212+
213+
def get_interpreter_for_requirement(requires_python, requested_python):
214+
"""Honor a PEP 723 'requires-python' specifier, returning the interpreter to use.
215+
216+
The returned value is the python executable/path to use, which may be
217+
'requested_python' unchanged or an auto-discovered one. Raises FadesError if no
218+
available interpreter satisfies the specifier.
219+
"""
220+
if not requires_python:
221+
return requested_python
222+
223+
try:
224+
specifier = SpecifierSet(requires_python)
225+
except (InvalidSpecifier, TypeError) as error:
226+
# TypeError happens when requires-python is not a string (e.g. a TOML number)
227+
logger.error("Invalid PEP 723 requires-python %r: %s", requires_python, error)
228+
raise FadesError("Invalid PEP 723 requires-python")
229+
logger.debug("Honoring PEP 723 requires-python %r", requires_python)
230+
231+
# check the currently selected interpreter (explicit -p or fades' own)
232+
major, minor, micro = _get_interpreter_full_version(requested_python)
233+
current_version = Version("{}.{}.{}".format(major, minor, micro))
234+
if specifier.contains(current_version, prereleases=True):
235+
return requested_python
236+
237+
if requested_python is not None:
238+
# the user explicitly chose an interpreter that conflicts with the script; don't
239+
# silently override that choice, fail so they can resolve it
240+
msg = ("The chosen Python interpreter (version {}) does not satisfy the script's "
241+
"requires-python ({!r})".format(current_version, requires_python))
242+
logger.error(msg)
243+
raise FadesError(msg)
244+
245+
# nothing was explicitly requested and fades' own python doesn't satisfy the spec:
246+
# try to discover a suitable interpreter in PATH
247+
discovered = _find_interpreter(specifier)
248+
if discovered is None:
249+
msg = ("No available Python interpreter satisfies the script's requires-python "
250+
"({!r})".format(requires_python))
251+
logger.error(msg)
252+
raise FadesError(msg)
253+
logger.info("Using Python interpreter %r to satisfy requires-python %r",
254+
discovered, requires_python)
255+
return discovered
256+
257+
165258
def get_latest_version_number(project_name):
166259
"""Return latest version of a package."""
167260
try:

fades/main.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,14 @@ def consolidate_dependencies(needs_ipython, child_program, requirement_files, ma
132132
logger.debug("Dependencies from source file: %s", srcfile_deps)
133133
docstring_deps = parsing.parse_docstring(child_program)
134134
logger.debug("Dependencies from docstrings: %s", docstring_deps)
135+
pep723_deps, _ = parsing.parse_pep723(child_program)
136+
logger.debug("Dependencies from PEP 723 metadata: %s", pep723_deps)
135137
else:
136138
srcfile_deps = {}
137139
docstring_deps = {}
140+
pep723_deps = {}
138141

139-
all_dependencies = [ipython_dep, srcfile_deps, docstring_deps]
142+
all_dependencies = [ipython_dep, srcfile_deps, docstring_deps, pep723_deps]
140143

141144
if requirement_files is not None:
142145
for rf_path in requirement_files:
@@ -388,8 +391,13 @@ def go():
388391
if args.check_updates:
389392
helpers.check_pypi_updates(indicated_deps)
390393

394+
# honor a PEP 723 'requires-python' (this may pick a different interpreter than the one
395+
# explicitly requested with --python or fades' own)
396+
_, requires_python = parsing.parse_pep723(analyzable_child_program)
397+
python_interpreter = helpers.get_interpreter_for_requirement(requires_python, args.python)
398+
391399
# get the interpreter version requested for the child_program
392-
interpreter, is_current = helpers.get_interpreter_version(args.python)
400+
interpreter, is_current = helpers.get_interpreter_version(python_interpreter)
393401

394402
# options
395403
pip_options = args.pip_options # pip_options mustn't store.
@@ -423,7 +431,8 @@ def go():
423431

424432
# Create a new venv
425433
venv_data, installed = envbuilder.create_venv(
426-
indicated_deps, args.python, is_current, options, pip_options, args.avoid_pip_upgrade)
434+
indicated_deps, python_interpreter, is_current, options, pip_options,
435+
args.avoid_pip_upgrade)
427436
# store this new venv in the cache
428437
venvscache.store(installed, venv_data, interpreter, options)
429438

fades/parsing.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,27 @@
2121
from pathlib import Path
2222
from typing import Generator
2323

24-
from packaging.requirements import Requirement
24+
try:
25+
import tomllib
26+
except ModuleNotFoundError: # Python < 3.11
27+
try:
28+
import tomli as tomllib
29+
except ModuleNotFoundError:
30+
tomllib = None # PEP 723 support degrades gracefully; warned at use site
31+
32+
from packaging.requirements import InvalidRequirement, Requirement
2533
from packaging.version import Version
2634

27-
from fades import REPO_PYPI, REPO_VCS
35+
from fades import FadesError, REPO_PYPI, REPO_VCS
2836
from fades.pkgnamesdb import MODULE_TO_PACKAGE
2937

3038
logger = logging.getLogger(__name__)
3139

40+
# Canonical regular expression to find a PEP 723 metadata block (see
41+
# https://peps.python.org/pep-0723/#reference-implementation).
42+
PEP723_REGEX = re.compile(
43+
r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$')
44+
3245

3346
class _VCSSpecifier:
3447
"""A simple specifier that works with VCSDependency."""
@@ -304,3 +317,64 @@ def parse_docstring(filepath: Path):
304317
return {}
305318
with open(filepath, 'rt', encoding='utf8') as fh:
306319
return _parse_docstring(fh)
320+
321+
322+
def _parse_pep723(content):
323+
"""Parse a PEP 723 inline metadata block.
324+
325+
Return a tuple ``(deps, requires_python)`` where ``deps`` is the usual repo->deps
326+
mapping and ``requires_python`` is the raw 'requires-python' specifier (or None).
327+
"""
328+
matches = [m for m in PEP723_REGEX.finditer(content) if m.group('type') == 'script']
329+
if not matches:
330+
return {}, None
331+
if len(matches) > 1:
332+
# The PEP mandates that tools error when several blocks of the same type exist.
333+
logger.error("Found %d PEP 723 'script' blocks, but only one is allowed", len(matches))
334+
raise FadesError("Multiple PEP 723 'script' blocks found")
335+
logger.debug("Found a PEP 723 'script' metadata block")
336+
337+
if tomllib is None:
338+
logger.warning(
339+
"Found a PEP 723 metadata block but no TOML parser is available; "
340+
"install the 'tomli' package to enable PEP 723 support in Python <3.11")
341+
return {}, None
342+
343+
# Rebuild the TOML content stripping the comment prefix of each line, as per the PEP.
344+
toml_content = ''.join(
345+
line[2:] if line.startswith('# ') else line[1:]
346+
for line in matches[0].group('content').splitlines(keepends=True))
347+
try:
348+
metadata = tomllib.loads(toml_content)
349+
except tomllib.TOMLDecodeError as error:
350+
logger.error("Invalid TOML in the PEP 723 metadata block: %s", error)
351+
raise FadesError("Invalid TOML in the PEP 723 metadata block")
352+
logger.debug("Parsed PEP 723 metadata: %s", metadata)
353+
354+
deps = {}
355+
dependencies = metadata.get('dependencies', [])
356+
if not isinstance(dependencies, list):
357+
logger.error(
358+
"PEP 723 'dependencies' must be a list, got %s", type(dependencies).__name__)
359+
raise FadesError("PEP 723 'dependencies' must be a list")
360+
if dependencies:
361+
# PEP 723 dependencies are standard PEP 508 strings (including 'name @ url' direct
362+
# references that pip understands), so they all go to the PyPI repo.
363+
try:
364+
deps[REPO_PYPI] = [Requirement(dep) for dep in dependencies]
365+
except InvalidRequirement as error:
366+
logger.error("Invalid dependency in the PEP 723 metadata block: %s", error)
367+
raise FadesError("Invalid dependency in the PEP 723 metadata block")
368+
369+
return deps, metadata.get('requires-python')
370+
371+
372+
def parse_pep723(filepath):
373+
"""Parse a source file's PEP 723 metadata block.
374+
375+
Return a tuple ``(deps, requires_python)``; see ``_parse_pep723``.
376+
"""
377+
if filepath is None:
378+
return {}, None
379+
with open(filepath, 'rt', encoding='utf8') as fh:
380+
return _parse_pep723(fh.read())

man/fades.1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ Select which Python version to be used; the argument can be just the number (3.1
8686

8787
The dependencies can be indicated in multiple places (in the Python source file, with a comment besides the import, in a \fIrequirements\fRfile, and/or through command line. In case of multiple definitions of the same dependency, command line overrides everything else, and requirements file overrides what is specified in the source code.
8888

89+
\fBfades\fR also understands the inline script metadata defined by PEP 723 (a \fB# /// script\fR block at the top of the script); its \fBdependencies\fR are installed, and if \fBrequires-python\fR is present it is used to select a suitable Python interpreter (failing if none is available).
90+
8991
.TP
9092
.BR -x ", " --exec
9193
Execute the \fIchild_program\fR in the context of the virtual environment. The child_program, which in this case becomes a mandatory parameter, can be just the executable name (relative to the venv's bin directory) or an absolute path.

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ pytest
66
pyuca
77
pyxdg
88
rst2html5
9+
tomli

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env python3
22

3-
# Copyright 2014-2024 Facundo Batista, Nicolás Demarchi
3+
# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
44
#
55
# This program is free software: you can redistribute it and/or modify it
66
# under the terms of the GNU General Public License version 3, as published
@@ -103,7 +103,7 @@ def finalize_options(self):
103103
cmdclass={
104104
'install': CustomInstall,
105105
},
106-
install_requires=['setuptools'],
106+
install_requires=['setuptools', 'tomli; python_version < "3.11"'],
107107
tests_require=['logassert', 'pyxdg', 'pyuca', 'pytest', 'flake8',
108108
'pep257', 'rst2html5'], # what unittests require
109109
python_requires='>=3.10', # Minimum Python version supported.

tests/test_files/pep723_basic.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
2+
3+
# /// script
4+
# dependencies = [
5+
# "requests<3",
6+
# "rich",
7+
# ]
8+
# ///
9+
10+
import requests
11+
from rich.pretty import pprint
12+
13+
pprint(requests.get("https://example.com"))
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
2+
3+
# /// script
4+
# requires-python = ">=3.11"
5+
# dependencies = [
6+
# "requests<3",
7+
# ]
8+
# ///
9+
10+
import requests
11+
12+
requests.get("https://example.com")

0 commit comments

Comments
 (0)