From 55c4e93c5dafa66e1c1af11f2fdafe8b0049d1da Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Thu, 18 Jun 2026 11:33:00 +0200 Subject: [PATCH 1/3] 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 --- README.rst | 25 ++- fades/helpers.py | 93 +++++++++++ fades/main.py | 15 +- fades/parsing.py | 78 ++++++++- man/fades.1 | 2 + requirements.txt | 1 + setup.py | 4 +- tests/test_files/pep723_basic.py | 13 ++ tests/test_files/pep723_requires_python.py | 12 ++ tests/test_helpers.py | 136 +++++++++++++++- tests/test_main.py | 10 +- tests/test_parsing/test_pep723.py | 181 +++++++++++++++++++++ 12 files changed, 559 insertions(+), 11 deletions(-) create mode 100644 tests/test_files/pep723_basic.py create mode 100644 tests/test_files/pep723_requires_python.py create mode 100644 tests/test_parsing/test_pep723.py 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..7ecba0f 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: 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_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..0a13620 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/" @@ -582,3 +583,134 @@ 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_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_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..29af7ad 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 @@ -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'])] diff --git a/tests/test_parsing/test_pep723.py b/tests/test_parsing/test_pep723.py new file mode 100644 index 0000000..a3a60b7 --- /dev/null +++ b/tests/test_parsing/test_pep723.py @@ -0,0 +1,181 @@ +# 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_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" From 728013659a8a150cc904a76d7721311d4bc3a48f Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 11:17:34 +0200 Subject: [PATCH 2/3] Check PyPI existence by name for non-exact version specifiers The PyPI availability pre-check built a version-specific URL whenever a dependency had any specifier, using the first specifier's version. For range specifiers like 'requests<3' this queried a non-existent version (requests/3) and wrongly reported the package as missing. PEP 723 scripts commonly use such range specifiers, so the bug surfaced there. Only use the version-specific URL for exact pins (== / ===); check by package name otherwise. Co-Authored-By: Claude Opus 4.8 --- fades/helpers.py | 9 ++++++--- tests/test_helpers.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/fades/helpers.py b/fades/helpers.py index 7ecba0f..d7ee3a7 100644 --- a/fades/helpers.py +++ b/fades/helpers.py @@ -310,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/tests/test_helpers.py b/tests/test_helpers.py index 0a13620..8e86722 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -384,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.""" From 45584f1e947e2a610485966a2fc0ed4fdd1390dc Mon Sep 17 00:00:00 2001 From: gilgamezh Date: Sat, 20 Jun 2026 11:28:39 +0200 Subject: [PATCH 3/3] Add PEP 723 test coverage for version handling and source merging Cover previously-untested cases: - requires-python bounded ranges (>=3.10,<3.12) in parsing and in interpreter selection/discovery (pick highest within the range) - exact micro-version pins (==3.11.4) satisfying or not - merging PEP 723 deps with comment-style and other dependency sources - end-to-end wiring of requires-python into interpreter resolution (the parse_pep723 -> get_interpreter_for_requirement flow from main.go) Co-Authored-By: Claude Opus 4.8 --- tests/test_files/pep723_and_comment.py | 13 +++++ tests/test_helpers.py | 44 +++++++++++++++ tests/test_main.py | 77 +++++++++++++++++++++++++- tests/test_parsing/test_pep723.py | 12 ++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/test_files/pep723_and_comment.py 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_helpers.py b/tests/test_helpers.py index 8e86722..e3b5294 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -654,6 +654,32 @@ def test_default_does_not_satisfy_no_candidate(self): 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): @@ -688,6 +714,24 @@ def test_picks_highest_satisfying(self): 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')) diff --git a/tests/test_main.py b/tests/test_main.py index 29af7ad..21f553f 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -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 @@ -146,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'] @@ -161,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 index a3a60b7..3f6e743 100644 --- a/tests/test_parsing/test_pep723.py +++ b/tests/test_parsing/test_pep723.py @@ -58,6 +58,18 @@ def test_requires_python(): 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"