diff --git a/news/12891.feature.rst b/news/12891.feature.rst new file mode 100644 index 00000000000..fa646cbcc0c --- /dev/null +++ b/news/12891.feature.rst @@ -0,0 +1,2 @@ +Support installing dependencies declared with inline script metadata +(PEP 723). diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 0b7cff77bdd..89ce6a7a17a 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -428,6 +428,20 @@ def requirements() -> Option: ) +# NOTE:2024-10-05:snoopj:it's simplest to allow exactly one file for a first pass +# https://github.com/pypa/pip/issues/12891 +def scripts() -> Option: + return Option( + "-s", + "--script", + action="append", + default=[], + dest="scripts", + metavar="file", + help="Install PEP 723 inline dependencies of the given script file. ", + ) + + def editable() -> Option: return Option( "-e", diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 92900f94ff4..294dc9f4068 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -10,13 +10,21 @@ from optparse import Values from typing import Any, List, Optional, Tuple +from pip._vendor.packaging.requirements import Requirement + from pip._internal.cache import WheelCache from pip._internal.cli import cmdoptions +from pip._internal.cli.cmdoptions import make_target_python from pip._internal.cli.index_command import IndexGroupCommand from pip._internal.cli.index_command import SessionCommandMixin as SessionCommandMixin -from pip._internal.exceptions import CommandError, PreviousBuildDirError +from pip._internal.exceptions import ( + CommandError, + PreviousBuildDirError, + UnsupportedPythonVersion, +) from pip._internal.index.collector import LinkCollector from pip._internal.index.package_finder import PackageFinder +from pip._internal.metadata.pep723 import pep723_metadata from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython from pip._internal.network.session import PipSession @@ -31,6 +39,7 @@ from pip._internal.req.req_file import parse_requirements from pip._internal.req.req_install import InstallRequirement from pip._internal.resolution.base import BaseResolver +from pip._internal.utils.packaging import check_requires_python from pip._internal.utils.temp_dir import ( TempDirectory, TempDirectoryTypeRegistry, @@ -268,11 +277,37 @@ def get_requirements( ) requirements.append(req_to_add) + if options.scripts: + if len(options.scripts) > 1: + raise CommandError("--script can only be given once") + + script = options.scripts[0] + script_metadata = pep723_metadata(script) + + script_requires_python = script_metadata.get("requires-python", "") + + if script_requires_python and not options.ignore_requires_python: + target_python = make_target_python(options) + + if not check_requires_python( + requires_python=script_requires_python, + version_info=target_python.py_version_info, + ): + raise UnsupportedPythonVersion( + f"Script {script!r} requires a different Python: " + f"{target_python.py_version} not in {script_requires_python!r}" + ) + + for req in script_metadata.get("dependencies", []): + requirements.append( + InstallRequirement(Requirement(req), comes_from=None) + ) + # If any requirement has hash options, enable hash checking. if any(req.has_hash_options for req in requirements): options.require_hashes = True - if not (args or options.editables or options.requirements): + if not (args or options.editables or options.requirements or options.scripts): opts = {"name": self.name} if options.find_links: raise CommandError( diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 917bbb91d83..4e517eb3c3a 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -38,6 +38,7 @@ class DownloadCommand(RequirementCommand): def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.constraints()) self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.scripts()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.global_options()) self.cmd_opts.add_option(cmdoptions.no_binary()) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index ad45a2f2a57..ed997a2e80b 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -73,6 +73,7 @@ class InstallCommand(RequirementCommand): def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.requirements()) self.cmd_opts.add_option(cmdoptions.constraints()) + self.cmd_opts.add_option(cmdoptions.scripts()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 278719f4e0c..b1b1e3cd097 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -63,6 +63,7 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.constraints()) self.cmd_opts.add_option(cmdoptions.editable()) self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.scripts()) self.cmd_opts.add_option(cmdoptions.src()) self.cmd_opts.add_option(cmdoptions.ignore_requires_python()) self.cmd_opts.add_option(cmdoptions.no_deps()) diff --git a/src/pip/_internal/metadata/pep723.py b/src/pip/_internal/metadata/pep723.py new file mode 100644 index 00000000000..8b23df63705 --- /dev/null +++ b/src/pip/_internal/metadata/pep723.py @@ -0,0 +1,26 @@ +import re +from typing import Any, Dict + +from pip._vendor import tomli as tomllib + +REGEX = r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$" + + +def pep723_metadata(scriptfile: str) -> Dict[str, Any]: + with open(scriptfile) as f: + script = f.read() + + name = "script" + matches = list( + filter(lambda m: m.group("type") == name, re.finditer(REGEX, script)) + ) + if len(matches) > 1: + raise ValueError(f"Multiple {name} blocks found") + elif len(matches) == 1: + content = "".join( + line[2:] if line.startswith("# ") else line[1:] + for line in matches[0].group("content").splitlines(keepends=True) + ) + return tomllib.loads(content) + else: + raise ValueError(f"File does not contain 'script' metadata: {scriptfile!r}") diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py new file mode 100644 index 00000000000..df1ea1bf9fb --- /dev/null +++ b/tests/functional/test_install_script.py @@ -0,0 +1,95 @@ +import sys +import textwrap + +import pytest + +from tests.lib import PipTestEnvironment + + +# TODO:2024-10-05:snoopj:need a test for requires-python support, too. +# Implement in terms of sys.version_info ? +@pytest.mark.network +def test_script_file(script: PipTestEnvironment) -> None: + """ + Test installing from a script with inline metadata (PEP 723). + """ + + other_lib_name, other_lib_version = "peppercorn", "0.6" + script_path = script.scratch_path.joinpath("script.py") + script_path.write_text( + textwrap.dedent( + f"""\ + # /// script + # dependencies = [ + # "INITools==0.2", + # "{other_lib_name}<={other_lib_version}", + # ] + # /// + + print("Hello world from a dummy program") + """ + ) + ) + result = script.pip("install", "--script", script_path) + + # NOTE:2024-10-05:snoopj:assertions same as in test_requirements_file + result.did_create(script.site_packages / "INITools-0.2.dist-info") + result.did_create(script.site_packages / "initools") + assert result.files_created[script.site_packages / other_lib_name].dir + fn = f"{other_lib_name}-{other_lib_version}.dist-info" + assert result.files_created[script.site_packages / fn].dir + + +def test_multiple_scripts(script: PipTestEnvironment) -> None: + """ + Test that --script can only be given once in an install command. + """ + result = script.pip( + "install", + "--script", + "does_not_exist.py", + "--script", + "also_does_not_exist.py", + allow_stderr_error=True, + expect_error=True, + ) + + assert "ERROR: --script can only be given once" in result.stderr, ( + "multiple script did not fail as expected -- " + result.stderr + ) + + +@pytest.mark.network +def test_script_file_python_version(script: PipTestEnvironment) -> None: + """ + Test installing from a script with an incompatible `requires-python` + """ + + other_lib_name, other_lib_version = "peppercorn", "0.6" + script_path = script.scratch_path.joinpath("script.py") + target_python_ver = f"{sys.version_info.major}.{sys.version_info.minor + 1}" + script_path.write_text( + textwrap.dedent( + f"""\ + # /// script + # requires-python = ">={target_python_ver}" + # dependencies = [ + # "INITools==0.2", + # "{other_lib_name}<={other_lib_version}", + # ] + # /// + + print("Hello world from a dummy program") + """ + ) + ) + + result = script.pip( + "install", "--script", script_path, expect_stderr=True, expect_error=True + ) + assert ( + f"ERROR: Script '{script_path}' requires a different Python" in result.stderr + ), ( + "Script with incompatible requires-python did not fail as expected -- " + + result.stderr + )