diff --git a/README.rst b/README.rst
index 55d30ec..b0d0faa 100644
--- a/README.rst
+++ b/README.rst
@@ -173,7 +173,7 @@ 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.
-Finally, you can include package names in the script docstring, after
+You can also include package names in the script docstring, after
a line where "fades" is written, until the end of the docstring;
for example::
@@ -186,6 +186,29 @@ for example::
otherpackage
"""
+Finally, *fades* understands the inline script metadata defined by
+`PEP 723 `_, so it can run scripts written
+for other runners (like ``pipx`` or ``pip-run``) and vice versa. Just add a
+``# /// script`` block at the top of the script::
+
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "requests<3",
+ # "rich",
+ # ]
+ # ///
+
+ import requests
+ from rich.pretty import pprint
+
+The ``dependencies`` are installed like any other dependency. If
+``requires-python`` is present, *fades* will use it to pick a suitable Python
+interpreter: if the one in use (the default, or the one given with
+``--python``) does not satisfy the requirement, *fades* will try to find an
+appropriate interpreter in the system, and fail if none is available (in that
+case, adjust the script's ``requires-python`` or install a matching Python).
+
About different repositories
----------------------------
diff --git a/fades/helpers.py b/fades/helpers.py
index 2a0ccc1..d7ee3a7 100644
--- a/fades/helpers.py
+++ b/fades/helpers.py
@@ -17,6 +17,7 @@
"""A collection of utilities for fades."""
import json
+import shutil
import logging
import os
import subprocess
@@ -28,12 +29,17 @@
from urllib.error import HTTPError
from packaging.requirements import Requirement
+from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import Version
from fades import FadesError, _version
logger = logging.getLogger(__name__)
+# range of CPython 3 minor versions probed when auto-selecting an interpreter to
+# satisfy a PEP 723 'requires-python' specifier
+PYTHON_MINOR_RANGE = range(6, 30)
+
# command to retrieve the version from an external Python
SHOW_VERSION_CMD = """
import sys, json
@@ -162,6 +168,93 @@ def get_interpreter_version(requested_interpreter):
return (requested_interpreter, is_current)
+def _get_interpreter_full_version(interpreter=None):
+ """Return the (major, minor, micro) version tuple of an interpreter."""
+ if interpreter is None:
+ return tuple(sys.version_info[:3])
+ args = [interpreter, '-c', SHOW_VERSION_CMD]
+ try:
+ raw = logged_exec(args)
+ # parse inside the try: a noisy interpreter (e.g. a shim printing to stderr, which
+ # logged_exec merges into stdout) can make raw[0] not be the expected JSON; turning
+ # any such failure into a FadesError lets _find_interpreter skip that candidate
+ info = json.loads(raw[0])
+ return (info['major'], info['minor'], info['micro'])
+ except Exception as error:
+ logger.error("Error getting requested interpreter version: %s", error)
+ raise FadesError("Could not get interpreter version")
+
+
+def _find_interpreter(specifier):
+ """Search PATH for a python interpreter whose version satisfies the specifier."""
+ candidate_names = ["python3.{}".format(minor) for minor in PYTHON_MINOR_RANGE]
+ candidate_names += ["python3", "python"]
+
+ found = {} # path -> Version, to avoid probing the same interpreter twice
+ for name in candidate_names:
+ path = shutil.which(name)
+ if path is None or path in found:
+ continue
+ try:
+ major, minor, micro = _get_interpreter_full_version(path)
+ except FadesError:
+ continue
+ found[path] = Version("{}.{}.{}".format(major, minor, micro))
+
+ candidates = sorted(
+ (version, path) for path, version in found.items()
+ if specifier.contains(version, prereleases=True))
+ if not candidates:
+ return None
+ # pick the highest satisfying version
+ return candidates[-1][1]
+
+
+def get_interpreter_for_requirement(requires_python, requested_python):
+ """Honor a PEP 723 'requires-python' specifier, returning the interpreter to use.
+
+ The returned value is the python executable/path to use, which may be
+ 'requested_python' unchanged or an auto-discovered one. Raises FadesError if no
+ available interpreter satisfies the specifier.
+ """
+ if not requires_python:
+ return requested_python
+
+ try:
+ specifier = SpecifierSet(requires_python)
+ except (InvalidSpecifier, TypeError) as error:
+ # TypeError happens when requires-python is not a string (e.g. a TOML number)
+ logger.error("Invalid PEP 723 requires-python %r: %s", requires_python, error)
+ raise FadesError("Invalid PEP 723 requires-python")
+ logger.debug("Honoring PEP 723 requires-python %r", requires_python)
+
+ # check the currently selected interpreter (explicit -p or fades' own)
+ major, minor, micro = _get_interpreter_full_version(requested_python)
+ current_version = Version("{}.{}.{}".format(major, minor, micro))
+ if specifier.contains(current_version, prereleases=True):
+ return requested_python
+
+ if requested_python is not None:
+ # the user explicitly chose an interpreter that conflicts with the script; don't
+ # silently override that choice, fail so they can resolve it
+ msg = ("The chosen Python interpreter (version {}) does not satisfy the script's "
+ "requires-python ({!r})".format(current_version, requires_python))
+ logger.error(msg)
+ raise FadesError(msg)
+
+ # nothing was explicitly requested and fades' own python doesn't satisfy the spec:
+ # try to discover a suitable interpreter in PATH
+ discovered = _find_interpreter(specifier)
+ if discovered is None:
+ msg = ("No available Python interpreter satisfies the script's requires-python "
+ "({!r})".format(requires_python))
+ logger.error(msg)
+ raise FadesError(msg)
+ logger.info("Using Python interpreter %r to satisfy requires-python %r",
+ discovered, requires_python)
+ return discovered
+
+
def get_latest_version_number(project_name):
"""Return latest version of a package."""
try:
@@ -217,9 +310,12 @@ def check_pypi_updates(dependencies):
def _pypi_head_package(dependency):
"""Hit pypi with a http HEAD to check if pkg_name exists."""
- if dependency.specifier:
- spec = list(dependency.specifier)[0]
- version = spec.version
+ # Only an exact pin (== or ===) maps to a version-specific URL; range specifiers
+ # like '<3' or '>=2' must be checked by package name, otherwise we'd query a
+ # non-existent "version" and wrongly conclude the package is missing.
+ exact_specs = [spec for spec in dependency.specifier if spec.operator in ("==", "===")]
+ if exact_specs:
+ version = exact_specs[0].version
url = BASE_PYPI_URL_WITH_VERSION.format(name=dependency.name, version=version)
else:
url = BASE_PYPI_URL.format(name=dependency.name)
diff --git a/fades/main.py b/fades/main.py
index 7256691..0914a51 100644
--- a/fades/main.py
+++ b/fades/main.py
@@ -132,11 +132,14 @@ def consolidate_dependencies(needs_ipython, child_program, requirement_files, ma
logger.debug("Dependencies from source file: %s", srcfile_deps)
docstring_deps = parsing.parse_docstring(child_program)
logger.debug("Dependencies from docstrings: %s", docstring_deps)
+ pep723_deps, _ = parsing.parse_pep723(child_program)
+ logger.debug("Dependencies from PEP 723 metadata: %s", pep723_deps)
else:
srcfile_deps = {}
docstring_deps = {}
+ pep723_deps = {}
- all_dependencies = [ipython_dep, srcfile_deps, docstring_deps]
+ all_dependencies = [ipython_dep, srcfile_deps, docstring_deps, pep723_deps]
if requirement_files is not None:
for rf_path in requirement_files:
@@ -388,8 +391,13 @@ def go():
if args.check_updates:
helpers.check_pypi_updates(indicated_deps)
+ # honor a PEP 723 'requires-python' (this may pick a different interpreter than the one
+ # explicitly requested with --python or fades' own)
+ _, requires_python = parsing.parse_pep723(analyzable_child_program)
+ python_interpreter = helpers.get_interpreter_for_requirement(requires_python, args.python)
+
# get the interpreter version requested for the child_program
- interpreter, is_current = helpers.get_interpreter_version(args.python)
+ interpreter, is_current = helpers.get_interpreter_version(python_interpreter)
# options
pip_options = args.pip_options # pip_options mustn't store.
@@ -423,7 +431,8 @@ def go():
# Create a new venv
venv_data, installed = envbuilder.create_venv(
- indicated_deps, args.python, is_current, options, pip_options, args.avoid_pip_upgrade)
+ indicated_deps, python_interpreter, is_current, options, pip_options,
+ args.avoid_pip_upgrade)
# store this new venv in the cache
venvscache.store(installed, venv_data, interpreter, options)
diff --git a/fades/parsing.py b/fades/parsing.py
index 52978fd..16874c5 100644
--- a/fades/parsing.py
+++ b/fades/parsing.py
@@ -21,14 +21,27 @@
from pathlib import Path
from typing import Generator
-from packaging.requirements import Requirement
+try:
+ import tomllib
+except ModuleNotFoundError: # Python < 3.11
+ try:
+ import tomli as tomllib
+ except ModuleNotFoundError:
+ tomllib = None # PEP 723 support degrades gracefully; warned at use site
+
+from packaging.requirements import InvalidRequirement, Requirement
from packaging.version import Version
-from fades import REPO_PYPI, REPO_VCS
+from fades import FadesError, REPO_PYPI, REPO_VCS
from fades.pkgnamesdb import MODULE_TO_PACKAGE
logger = logging.getLogger(__name__)
+# Canonical regular expression to find a PEP 723 metadata block (see
+# https://peps.python.org/pep-0723/#reference-implementation).
+PEP723_REGEX = re.compile(
+ r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$')
+
class _VCSSpecifier:
"""A simple specifier that works with VCSDependency."""
@@ -304,3 +317,64 @@ def parse_docstring(filepath: Path):
return {}
with open(filepath, 'rt', encoding='utf8') as fh:
return _parse_docstring(fh)
+
+
+def _parse_pep723(content):
+ """Parse a PEP 723 inline metadata block.
+
+ Return a tuple ``(deps, requires_python)`` where ``deps`` is the usual repo->deps
+ mapping and ``requires_python`` is the raw 'requires-python' specifier (or None).
+ """
+ matches = [m for m in PEP723_REGEX.finditer(content) if m.group('type') == 'script']
+ if not matches:
+ return {}, None
+ if len(matches) > 1:
+ # The PEP mandates that tools error when several blocks of the same type exist.
+ logger.error("Found %d PEP 723 'script' blocks, but only one is allowed", len(matches))
+ raise FadesError("Multiple PEP 723 'script' blocks found")
+ logger.debug("Found a PEP 723 'script' metadata block")
+
+ if tomllib is None:
+ logger.warning(
+ "Found a PEP 723 metadata block but no TOML parser is available; "
+ "install the 'tomli' package to enable PEP 723 support in Python <3.11")
+ return {}, None
+
+ # Rebuild the TOML content stripping the comment prefix of each line, as per the PEP.
+ toml_content = ''.join(
+ line[2:] if line.startswith('# ') else line[1:]
+ for line in matches[0].group('content').splitlines(keepends=True))
+ try:
+ metadata = tomllib.loads(toml_content)
+ except tomllib.TOMLDecodeError as error:
+ logger.error("Invalid TOML in the PEP 723 metadata block: %s", error)
+ raise FadesError("Invalid TOML in the PEP 723 metadata block")
+ logger.debug("Parsed PEP 723 metadata: %s", metadata)
+
+ deps = {}
+ dependencies = metadata.get('dependencies', [])
+ if not isinstance(dependencies, list):
+ logger.error(
+ "PEP 723 'dependencies' must be a list, got %s", type(dependencies).__name__)
+ raise FadesError("PEP 723 'dependencies' must be a list")
+ if dependencies:
+ # PEP 723 dependencies are standard PEP 508 strings (including 'name @ url' direct
+ # references that pip understands), so they all go to the PyPI repo.
+ try:
+ deps[REPO_PYPI] = [Requirement(dep) for dep in dependencies]
+ except InvalidRequirement as error:
+ logger.error("Invalid dependency in the PEP 723 metadata block: %s", error)
+ raise FadesError("Invalid dependency in the PEP 723 metadata block")
+
+ return deps, metadata.get('requires-python')
+
+
+def parse_pep723(filepath):
+ """Parse a source file's PEP 723 metadata block.
+
+ Return a tuple ``(deps, requires_python)``; see ``_parse_pep723``.
+ """
+ if filepath is None:
+ return {}, None
+ with open(filepath, 'rt', encoding='utf8') as fh:
+ return _parse_pep723(fh.read())
diff --git a/man/fades.1 b/man/fades.1
index 273ab28..2584bf2 100644
--- a/man/fades.1
+++ b/man/fades.1
@@ -86,6 +86,8 @@ Select which Python version to be used; the argument can be just the number (3.1
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.
+\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).
+
.TP
.BR -x ", " --exec
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.
diff --git a/requirements.txt b/requirements.txt
index dc2009e..b8593fa 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,3 +6,4 @@ pytest
pyuca
pyxdg
rst2html5
+tomli
diff --git a/setup.py b/setup.py
index 737ea0a..1f187e3 100755
--- a/setup.py
+++ b/setup.py
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
-# Copyright 2014-2024 Facundo Batista, Nicolás Demarchi
+# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
@@ -103,7 +103,7 @@ def finalize_options(self):
cmdclass={
'install': CustomInstall,
},
- install_requires=['setuptools'],
+ install_requires=['setuptools', 'tomli; python_version < "3.11"'],
tests_require=['logassert', 'pyxdg', 'pyuca', 'pytest', 'flake8',
'pep257', 'rst2html5'], # what unittests require
python_requires='>=3.10', # Minimum Python version supported.
diff --git a/tests/test_files/pep723_and_comment.py b/tests/test_files/pep723_and_comment.py
new file mode 100644
index 0000000..91a3acc
--- /dev/null
+++ b/tests/test_files/pep723_and_comment.py
@@ -0,0 +1,13 @@
+# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
+
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "requests<3",
+# ]
+# ///
+
+import requests # fades
+import rich # fades
+
+rich.print(requests.get("https://example.com"))
diff --git a/tests/test_files/pep723_basic.py b/tests/test_files/pep723_basic.py
new file mode 100644
index 0000000..b6cdb1c
--- /dev/null
+++ b/tests/test_files/pep723_basic.py
@@ -0,0 +1,13 @@
+# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
+
+# /// script
+# dependencies = [
+# "requests<3",
+# "rich",
+# ]
+# ///
+
+import requests
+from rich.pretty import pprint
+
+pprint(requests.get("https://example.com"))
diff --git a/tests/test_files/pep723_requires_python.py b/tests/test_files/pep723_requires_python.py
new file mode 100644
index 0000000..1bc6d59
--- /dev/null
+++ b/tests/test_files/pep723_requires_python.py
@@ -0,0 +1,12 @@
+# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
+
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "requests<3",
+# ]
+# ///
+
+import requests
+
+requests.get("https://example.com")
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 9b7284c..e3b5294 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2026 Facundo Batista, Nicolás Demarchi
+# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
@@ -30,9 +30,10 @@
import logassert
import pytest
+from packaging.specifiers import SpecifierSet
from xdg import BaseDirectory
-from fades import helpers, parsing
+from fades import helpers, parsing, FadesError
PATH_TO_EXAMPLES = "tests/examples/"
@@ -383,6 +384,35 @@ def test_redirect_response(self):
self.assertTrue(exists)
self.assertLoggedWarning("Got a (unexpected) HTTP_STATUS")
+ def test_exact_version_uses_version_url(self):
+ deps = parsing.parse_manual(["foo==2.0"])
+
+ with patch('urllib.request.urlopen') as mock_urlopen:
+ with patch('http.client.HTTPResponse') as mock_http_response:
+ mock_http_response.status = HTTPStatus.OK
+ mock_urlopen.return_value = mock_http_response
+
+ exists = helpers.check_pypi_exists(deps)
+
+ self.assertTrue(exists)
+ req = mock_urlopen.call_args[0][0]
+ self.assertEqual(req.full_url, "https://pypi.org/pypi/foo/2.0/json")
+
+ def test_range_version_uses_name_url(self):
+ # A range specifier must be checked by package name, not by an inexistent "version".
+ deps = parsing.parse_manual(["foo<3"])
+
+ with patch('urllib.request.urlopen') as mock_urlopen:
+ with patch('http.client.HTTPResponse') as mock_http_response:
+ mock_http_response.status = HTTPStatus.OK
+ mock_urlopen.return_value = mock_http_response
+
+ exists = helpers.check_pypi_exists(deps)
+
+ self.assertTrue(exists)
+ req = mock_urlopen.call_args[0][0]
+ self.assertEqual(req.full_url, "https://pypi.org/pypi/foo/json")
+
class ScriptDownloaderTestCase(unittest.TestCase):
"""Check the script downloader."""
@@ -582,3 +612,178 @@ def test_getbinpath_windows(tmp_path):
def test_getbinpath_missing(tmp_path):
with pytest.raises(ValueError):
helpers.get_env_bin_path(tmp_path)
+
+
+class GetInterpreterForRequirementTestCase(unittest.TestCase):
+ """Tests for honoring a PEP 723 requires-python."""
+
+ def test_no_requirement(self):
+ # nothing to honor, the requested interpreter is returned unchanged (and no
+ # interpreter version is even probed)
+ with patch.object(helpers, '_get_interpreter_full_version') as mock:
+ result = helpers.get_interpreter_for_requirement(None, '/some/python')
+ assert result == '/some/python'
+ assert mock.call_count == 0
+
+ def test_current_satisfies(self):
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 12, 0)):
+ result = helpers.get_interpreter_for_requirement('>=3.11', None)
+ assert result is None
+
+ def test_explicit_satisfies(self):
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 12, 0)):
+ result = helpers.get_interpreter_for_requirement('>=3.11', '/some/python')
+ assert result == '/some/python'
+
+ def test_explicit_does_not_satisfy(self):
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 9, 0)):
+ with pytest.raises(FadesError):
+ helpers.get_interpreter_for_requirement('>=3.11', '/some/python')
+
+ def test_default_does_not_satisfy_discovers(self):
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 9, 0)):
+ with patch.object(helpers, '_find_interpreter',
+ return_value='/usr/bin/python3.12') as mock_find:
+ result = helpers.get_interpreter_for_requirement('>=3.11', None)
+ assert result == '/usr/bin/python3.12'
+ assert mock_find.call_count == 1
+
+ def test_default_does_not_satisfy_no_candidate(self):
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 9, 0)):
+ with patch.object(helpers, '_find_interpreter', return_value=None):
+ with pytest.raises(FadesError):
+ helpers.get_interpreter_for_requirement('>=3.11', None)
+
+ def test_bounded_range_current_satisfies(self):
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 11, 0)):
+ result = helpers.get_interpreter_for_requirement('>=3.10,<3.12', None)
+ assert result is None
+
+ def test_bounded_range_current_excluded_discovers(self):
+ # the current interpreter is above the upper bound, so a suitable one is discovered
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 12, 0)):
+ with patch.object(helpers, '_find_interpreter',
+ return_value='/usr/bin/python3.11') as mock_find:
+ result = helpers.get_interpreter_for_requirement('>=3.10,<3.12', None)
+ assert result == '/usr/bin/python3.11'
+ assert mock_find.call_count == 1
+
+ def test_micro_version_pin_satisfies(self):
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 11, 4)):
+ result = helpers.get_interpreter_for_requirement('==3.11.4', None)
+ assert result is None
+
+ def test_micro_version_pin_does_not_satisfy(self):
+ # same major.minor but a different micro must not satisfy an exact pin
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 11, 5)):
+ with patch.object(helpers, '_find_interpreter', return_value=None):
+ with pytest.raises(FadesError):
+ helpers.get_interpreter_for_requirement('==3.11.4', None)
+
+ def test_invalid_requires_python_string(self):
+ logassert.setup(self, 'fades.helpers')
+ with pytest.raises(FadesError):
+ helpers.get_interpreter_for_requirement('not a spec!!', None)
+ self.assertLoggedError("Invalid PEP 723 requires-python", "not a spec!!")
+
+ def test_invalid_requires_python_not_a_string(self):
+ # a TOML number reaches us as a float, which is not a valid specifier
+ logassert.setup(self, 'fades.helpers')
+ with pytest.raises(FadesError):
+ helpers.get_interpreter_for_requirement(3.11, None)
+ self.assertLoggedError("Invalid PEP 723 requires-python")
+
+
+class FindInterpreterTestCase(unittest.TestCase):
+ """Tests for the PATH-based interpreter discovery."""
+
+ def test_picks_highest_satisfying(self):
+ whiches = {
+ 'python3.10': '/usr/bin/python3.10',
+ 'python3.11': '/usr/bin/python3.11',
+ 'python3.12': '/usr/bin/python3.12',
+ }
+ versions = {
+ '/usr/bin/python3.10': (3, 10, 5),
+ '/usr/bin/python3.11': (3, 11, 2),
+ '/usr/bin/python3.12': (3, 12, 1),
+ }
+ with patch.object(helpers.shutil, 'which', side_effect=whiches.get):
+ with patch.object(helpers, '_get_interpreter_full_version',
+ side_effect=lambda p: versions[p]):
+ result = helpers._find_interpreter(SpecifierSet('>=3.11'))
+ assert result == '/usr/bin/python3.12'
+
+ def test_picks_highest_within_bounded_range(self):
+ # with an upper bound, the highest *satisfying* one is picked, not the highest overall
+ whiches = {
+ 'python3.10': '/usr/bin/python3.10',
+ 'python3.11': '/usr/bin/python3.11',
+ 'python3.12': '/usr/bin/python3.12',
+ }
+ versions = {
+ '/usr/bin/python3.10': (3, 10, 5),
+ '/usr/bin/python3.11': (3, 11, 2),
+ '/usr/bin/python3.12': (3, 12, 1),
+ }
+ with patch.object(helpers.shutil, 'which', side_effect=whiches.get):
+ with patch.object(helpers, '_get_interpreter_full_version',
+ side_effect=lambda p: versions[p]):
+ result = helpers._find_interpreter(SpecifierSet('>=3.10,<3.12'))
+ assert result == '/usr/bin/python3.11'
+
+ def test_none_found(self):
+ with patch.object(helpers.shutil, 'which', return_value=None):
+ result = helpers._find_interpreter(SpecifierSet('>=3.11'))
+ assert result is None
+
+ def test_skips_unprobable_interpreters(self):
+ # a found interpreter that can't report its version is just skipped
+ def fake_version(path):
+ raise FadesError("boom")
+
+ with patch.object(helpers.shutil, 'which', return_value='/usr/bin/python3.12'):
+ with patch.object(helpers, '_get_interpreter_full_version',
+ side_effect=fake_version):
+ result = helpers._find_interpreter(SpecifierSet('>=3.11'))
+ assert result is None
+
+ def test_skips_noisy_interpreter_without_crashing(self):
+ # a real interpreter that prints non-JSON noise first (e.g. a shim warning, which
+ # logged_exec merges from stderr) must be skipped, not crash the whole discovery
+ with patch.object(helpers.shutil, 'which', return_value='/usr/bin/python3.12'):
+ with patch.object(helpers, 'logged_exec',
+ return_value=["Warning: this is not JSON"]):
+ result = helpers._find_interpreter(SpecifierSet('>=3.11'))
+ assert result is None
+
+
+class GetInterpreterFullVersionTestCase(unittest.TestCase):
+ """Tests for probing an interpreter's full version."""
+
+ def test_current_interpreter(self):
+ assert helpers._get_interpreter_full_version() == tuple(sys.version_info[:3])
+
+ def test_external_interpreter_ok(self):
+ response = ['{"path": "/usr/bin/python3.12", "major": 3, "minor": 12, "micro": 1}']
+ with patch.object(helpers, 'logged_exec', return_value=response):
+ assert helpers._get_interpreter_full_version('/usr/bin/python3.12') == (3, 12, 1)
+
+ def test_external_interpreter_exec_fails(self):
+ logassert.setup(self, 'fades.helpers')
+ with patch.object(helpers, 'logged_exec', side_effect=Exception("boom")):
+ with pytest.raises(FadesError):
+ helpers._get_interpreter_full_version('/usr/bin/python3.12')
+ self.assertLoggedError("Error getting requested interpreter version")
+
+ def test_external_interpreter_non_json_output(self):
+ # non-JSON first line must become a FadesError (not an uncaught JSONDecodeError)
+ with patch.object(helpers, 'logged_exec', return_value=["not json at all"]):
+ with pytest.raises(FadesError):
+ helpers._get_interpreter_full_version('/usr/bin/python3.12')
+
+ def test_external_interpreter_empty_output(self):
+ # no output at all must become a FadesError (not an uncaught IndexError)
+ with patch.object(helpers, 'logged_exec', return_value=[]):
+ with pytest.raises(FadesError):
+ helpers._get_interpreter_full_version('/usr/bin/python3.12')
diff --git a/tests/test_main.py b/tests/test_main.py
index 9e1d5bf..21f553f 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -1,4 +1,4 @@
-# Copyright 2015-2026 Facundo Batista, Nicolás Demarchi
+# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
@@ -23,7 +23,7 @@
from packaging.requirements import Requirement
-from fades import VERSION, FadesError, __version__, main, parsing, REPO_PYPI, REPO_VCS
+from fades import VERSION, FadesError, __version__, helpers, main, parsing, REPO_PYPI, REPO_VCS
from tests import create_tempfile
@@ -64,6 +64,14 @@ def test_child_program(self):
self.assertDictEqual(d, {'pypi': {Requirement('foo'), Requirement('bar')}})
+ def test_child_program_pep723(self):
+ child_program = 'tests/test_files/pep723_basic.py'
+
+ d = main.consolidate_dependencies(needs_ipython=False, child_program=child_program,
+ requirement_files=None, manual_dependencies=None)
+
+ self.assertDictEqual(d, {'pypi': {Requirement('requests<3'), Requirement('rich')}})
+
def test_requirement_files(self):
requirement_files = [create_tempfile(self, ['dep'])]
@@ -138,6 +146,32 @@ def test_one_duplicated(self):
'pypi': {Requirement('2')}
})
+ def test_pep723_merges_with_comment_deps(self):
+ # a file carrying both a PEP 723 block and comment-style 'fades' marks contributes
+ # the deps from both sources
+ child_program = 'tests/test_files/pep723_and_comment.py'
+
+ d = main.consolidate_dependencies(needs_ipython=False, child_program=child_program,
+ requirement_files=None, manual_dependencies=None)
+
+ self.assertDictEqual(d, {
+ 'pypi': {Requirement('requests<3'), Requirement('requests'), Requirement('rich')}
+ })
+
+ def test_pep723_merges_with_other_sources(self):
+ child_program = 'tests/test_files/pep723_basic.py'
+ requirement_files = [create_tempfile(self, ['from-reqfile'])]
+ manual_dependencies = ['from-manual']
+
+ d = main.consolidate_dependencies(needs_ipython=False, child_program=child_program,
+ requirement_files=requirement_files,
+ manual_dependencies=manual_dependencies)
+
+ self.assertDictEqual(d, {
+ 'pypi': {Requirement('requests<3'), Requirement('rich'),
+ Requirement('from-reqfile'), Requirement('from-manual')}
+ })
+
def test_two_different_with_dups(self):
requirement_files = [create_tempfile(self, ['1', '2', '2', '2'])]
manual_dependencies = ['vcs::3', 'vcs::4', 'vcs::1', 'vcs::2']
@@ -153,6 +187,55 @@ def test_two_different_with_dups(self):
})
+class PEP723RequiresPythonIntegrationTestCase(unittest.TestCase):
+ """End-to-end wiring of a PEP 723 requires-python into interpreter selection.
+
+ This mirrors the two-step flow in main.go(): parse the requires-python out of the
+ script and then resolve which interpreter honors it.
+ """
+
+ def _resolve(self, child_program, requested_python):
+ _, requires_python = parsing.parse_pep723(child_program)
+ return helpers.get_interpreter_for_requirement(requires_python, requested_python)
+
+ def test_requires_python_picks_discovered_interpreter(self):
+ # script asks for >=3.11; the default doesn't satisfy, so one is discovered
+ child_program = 'tests/test_files/pep723_requires_python.py'
+
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 9, 0)):
+ with patch.object(helpers, '_find_interpreter',
+ return_value='/usr/bin/python3.12'):
+ result = self._resolve(child_program, None)
+
+ self.assertEqual(result, '/usr/bin/python3.12')
+
+ def test_requires_python_satisfied_keeps_interpreter(self):
+ child_program = 'tests/test_files/pep723_requires_python.py'
+
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 12, 0)):
+ result = self._resolve(child_program, '/some/python')
+
+ self.assertEqual(result, '/some/python')
+
+ def test_requires_python_conflicts_with_explicit_choice(self):
+ # an explicit --python that violates the script's requires-python is an error
+ child_program = 'tests/test_files/pep723_requires_python.py'
+
+ with patch.object(helpers, '_get_interpreter_full_version', return_value=(3, 9, 0)):
+ with self.assertRaises(FadesError):
+ self._resolve(child_program, '/some/python')
+
+ def test_no_requires_python_keeps_interpreter(self):
+ # a PEP 723 block without requires-python leaves the chosen interpreter untouched
+ child_program = 'tests/test_files/pep723_basic.py'
+
+ with patch.object(helpers, '_get_interpreter_full_version') as mock:
+ result = self._resolve(child_program, '/some/python')
+
+ self.assertEqual(result, '/some/python')
+ self.assertEqual(mock.call_count, 0)
+
+
class MiscTestCase(unittest.TestCase):
"""Miscellaneous tests."""
diff --git a/tests/test_parsing/test_pep723.py b/tests/test_parsing/test_pep723.py
new file mode 100644
index 0000000..3f6e743
--- /dev/null
+++ b/tests/test_parsing/test_pep723.py
@@ -0,0 +1,193 @@
+# Copyright 2014-2026 Facundo Batista, Nicolás Demarchi
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License version 3, as published
+# by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranties of
+# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
+# PURPOSE. See the GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program. If not, see .
+#
+# For further info, check https://github.com/PyAr/fades
+
+"""Check the PEP 723 inline script metadata parsing."""
+
+from unittest.mock import patch
+
+import pytest
+
+from fades import FadesError, parsing, REPO_PYPI
+
+from tests import get_reqs
+
+
+def test_no_block():
+ deps, requires_python = parsing._parse_pep723("import time\nprint('hi')\n")
+ assert deps == {}
+ assert requires_python is None
+
+
+def test_simple_dependencies():
+ content = (
+ "# /// script\n"
+ "# dependencies = [\n"
+ '# "requests<3",\n'
+ '# "rich",\n'
+ "# ]\n"
+ "# ///\n"
+ "import requests\n"
+ )
+ deps, requires_python = parsing._parse_pep723(content)
+ assert deps == {REPO_PYPI: get_reqs("requests<3", "rich")}
+ assert requires_python is None
+
+
+def test_requires_python():
+ content = (
+ "# /// script\n"
+ '# requires-python = ">=3.11"\n'
+ "# dependencies = []\n"
+ "# ///\n"
+ )
+ deps, requires_python = parsing._parse_pep723(content)
+ assert deps == {}
+ assert requires_python == ">=3.11"
+
+
+def test_requires_python_bounded_range():
+ content = (
+ "# /// script\n"
+ '# requires-python = ">=3.10,<3.12"\n'
+ "# dependencies = []\n"
+ "# ///\n"
+ )
+ deps, requires_python = parsing._parse_pep723(content)
+ assert deps == {}
+ assert requires_python == ">=3.10,<3.12"
+
+
+def test_empty_dependencies():
+ content = (
+ "# /// script\n"
+ "# dependencies = []\n"
+ "# ///\n"
+ )
+ deps, requires_python = parsing._parse_pep723(content)
+ assert deps == {}
+ assert requires_python is None
+
+
+def test_non_script_block_is_ignored():
+ content = (
+ "# /// pyproject\n"
+ "# dependencies = [\n"
+ '# "requests",\n'
+ "# ]\n"
+ "# ///\n"
+ )
+ deps, requires_python = parsing._parse_pep723(content)
+ assert deps == {}
+ assert requires_python is None
+
+
+def test_comment_prefix_stripping():
+ # a bare '#' line (no trailing space) must be stripped of just one char, while
+ # '# ' lines are stripped of two
+ content = (
+ "# /// script\n"
+ "#\n"
+ "# dependencies = [\n"
+ '# "requests",\n'
+ "# ]\n"
+ "# ///\n"
+ )
+ deps, requires_python = parsing._parse_pep723(content)
+ assert deps == {REPO_PYPI: get_reqs("requests")}
+
+
+def test_multiple_script_blocks_error(logs):
+ content = (
+ "# /// script\n"
+ "# dependencies = []\n"
+ "# ///\n"
+ "\n"
+ "import sys\n"
+ "\n"
+ "# /// script\n"
+ "# dependencies = []\n"
+ "# ///\n"
+ )
+ with pytest.raises(FadesError):
+ parsing._parse_pep723(content)
+ assert "only one is allowed" in logs.error
+
+
+def test_invalid_toml(logs):
+ content = (
+ "# /// script\n"
+ '# dependencies = ["unclosed\n'
+ "# ///\n"
+ )
+ with pytest.raises(FadesError):
+ parsing._parse_pep723(content)
+ assert "Invalid TOML in the PEP 723 metadata block" in logs.error
+
+
+def test_invalid_dependency(logs):
+ content = (
+ "# /// script\n"
+ "# dependencies = [\n"
+ '# "not a valid requirement!!",\n'
+ "# ]\n"
+ "# ///\n"
+ )
+ with pytest.raises(FadesError):
+ parsing._parse_pep723(content)
+ assert "Invalid dependency in the PEP 723 metadata block" in logs.error
+
+
+def test_dependencies_not_a_list(logs):
+ content = (
+ "# /// script\n"
+ '# dependencies = "requests"\n'
+ "# ///\n"
+ )
+ with pytest.raises(FadesError):
+ parsing._parse_pep723(content)
+ assert "must be a list" in logs.error
+
+
+def test_no_toml_parser_available(logs):
+ content = (
+ "# /// script\n"
+ "# dependencies = [\n"
+ '# "requests",\n'
+ "# ]\n"
+ "# ///\n"
+ )
+ with patch.object(parsing, "tomllib", None):
+ deps, requires_python = parsing._parse_pep723(content)
+ assert deps == {}
+ assert requires_python is None
+ assert "no TOML parser is available" in logs.warning
+
+
+def test_parse_pep723_none():
+ assert parsing.parse_pep723(None) == ({}, None)
+
+
+def test_parse_pep723_file_basic():
+ deps, requires_python = parsing.parse_pep723("tests/test_files/pep723_basic.py")
+ assert deps == {REPO_PYPI: get_reqs("requests<3", "rich")}
+ assert requires_python is None
+
+
+def test_parse_pep723_file_requires_python():
+ deps, requires_python = parsing.parse_pep723(
+ "tests/test_files/pep723_requires_python.py")
+ assert deps == {REPO_PYPI: get_reqs("requests<3")}
+ assert requires_python == ">=3.11"