diff --git a/news/12891.feature.rst b/news/12891.feature.rst new file mode 100644 index 00000000000..7462188afbd --- /dev/null +++ b/news/12891.feature.rst @@ -0,0 +1,2 @@ +Support installing dependencies declared with inline script metadata +(:pep:`723`) with ``--requirements-from-script``. diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index a4737757b23..6ac73a0b6eb 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -479,6 +479,18 @@ def requirements() -> Option: ) +def requirements_from_scripts() -> Option: + return Option( + "--requirements-from-script", + action="append", + default=[], + dest="requirements_from_scripts", + metavar="file", + help="Install dependencies of the given script file" + "as defined by PEP 723 inline metadata. ", + ) + + 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 f6d7f81e82e..627a0de21f2 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -16,9 +16,14 @@ from pip._internal.build_env import SubprocessBuildEnvironmentInstaller 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.models.selection_prefs import SelectionPreferences @@ -32,10 +37,12 @@ install_req_from_parsed_requirement, install_req_from_req_string, ) +from pip._internal.req.pep723 import PEP723Exception, pep723_metadata from pip._internal.req.req_dependency_group import parse_dependency_groups 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, @@ -305,6 +312,38 @@ def get_requirements( ) requirements.append(req_to_add) + if options.requirements_from_scripts: + if len(options.requirements_from_scripts) > 1: + raise CommandError("--requirements-from-script can only be given once") + + script = options.requirements_from_scripts[0] + try: + script_metadata = pep723_metadata(script) + except PEP723Exception as exc: + raise CommandError(exc.msg) + + 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", []): + req_to_add = install_req_from_req_string( + req, + isolated=options.isolated_mode, + user_supplied=True, + ) + requirements.append(req_to_add) + # If any requirement has hash options, enable hash checking. if any(req.has_hash_options for req in requirements): options.require_hashes = True @@ -314,6 +353,7 @@ def get_requirements( or options.editables or options.requirements or options.dependency_groups + or options.requirements_from_scripts ): opts = {"name": self.name} if options.find_links: diff --git a/src/pip/_internal/commands/download.py b/src/pip/_internal/commands/download.py index 903917b9ba2..0f167d262dc 100644 --- a/src/pip/_internal/commands/download.py +++ b/src/pip/_internal/commands/download.py @@ -37,6 +37,7 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.constraints()) self.cmd_opts.add_option(cmdoptions.build_constraints()) self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.requirements_from_scripts()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.no_binary()) self.cmd_opts.add_option(cmdoptions.only_binary()) diff --git a/src/pip/_internal/commands/install.py b/src/pip/_internal/commands/install.py index b16b8e3dbcb..e815c51f6c1 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -87,6 +87,7 @@ 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.build_constraints()) + self.cmd_opts.add_option(cmdoptions.requirements_from_scripts()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) diff --git a/src/pip/_internal/commands/lock.py b/src/pip/_internal/commands/lock.py index b02fb95dacf..1031b999453 100644 --- a/src/pip/_internal/commands/lock.py +++ b/src/pip/_internal/commands/lock.py @@ -55,6 +55,7 @@ def add_options(self) -> None: ) ) self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.requirements_from_scripts()) self.cmd_opts.add_option(cmdoptions.constraints()) self.cmd_opts.add_option(cmdoptions.build_constraints()) self.cmd_opts.add_option(cmdoptions.no_deps()) diff --git a/src/pip/_internal/commands/wheel.py b/src/pip/_internal/commands/wheel.py index 28503940d46..db584ff6585 100644 --- a/src/pip/_internal/commands/wheel.py +++ b/src/pip/_internal/commands/wheel.py @@ -61,6 +61,7 @@ def add_options(self) -> None: self.cmd_opts.add_option(cmdoptions.build_constraints()) self.cmd_opts.add_option(cmdoptions.editable()) self.cmd_opts.add_option(cmdoptions.requirements()) + self.cmd_opts.add_option(cmdoptions.requirements_from_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/req/pep723.py b/src/pip/_internal/req/pep723.py new file mode 100644 index 00000000000..ed1cc9dc4d3 --- /dev/null +++ b/src/pip/_internal/req/pep723.py @@ -0,0 +1,41 @@ +import re +from typing import Any + +from pip._vendor import tomli as tomllib + +REGEX = r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$" + + +class PEP723Exception(ValueError): + """Raised to indicate a problem when parsing PEP 723 metadata from a script""" + + def __init__(self, msg: str) -> None: + self.msg = msg + + +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 PEP723Exception(f"Multiple {name!r} blocks found in {scriptfile!r}") + elif len(matches) == 1: + content = "".join( + line[2:] if line.startswith("# ") else line[1:] + for line in matches[0].group("content").splitlines(keepends=True) + ) + try: + metadata = tomllib.loads(content) + except Exception as exc: + raise PEP723Exception(f"Failed to parse TOML in {scriptfile!r}") from exc + else: + raise PEP723Exception( + f"File does not contain {name!r} metadata: {scriptfile!r}" + ) + + return metadata diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py new file mode 100644 index 00000000000..93219f09b22 --- /dev/null +++ b/tests/functional/test_install_script.py @@ -0,0 +1,159 @@ +import sys +import textwrap + +from tests.lib import PipTestEnvironment + + +def test_script_file(script: PipTestEnvironment) -> None: + """ + Test installing from a script with inline metadata (PEP 723). + """ + + script_path = script.scratch_path.joinpath("script.py") + script_path.write_text( + textwrap.dedent( + """\ + # /// script + # dependencies = [ + # "INITools==0.2", + # "simple==1.0", + # ] + # /// + + print("Hello world from a dummy program") + """ + ) + ) + script.pip_install_local("--requirements-from-script", script_path) + script.assert_installed(initools="0.2", simple="1.0") + + +def test_multiple_scripts(script: PipTestEnvironment) -> None: + """ + Test that --requirements-from-script can only be given once in an install command. + """ + result = script.pip( + "install", + "--requirements-from-script", + "does_not_exist.py", + "--requirements-from-script", + "also_does_not_exist.py", + allow_stderr_error=True, + expect_error=True, + ) + + assert ( + "ERROR: --requirements-from-script can only be given once" in result.stderr + ), ("multiple script did not fail as expected -- " + result.stderr) + + +def test_script_file_python_version(script: PipTestEnvironment) -> None: + """ + Test installing from a script with an incompatible `requires-python` + """ + + script_path = script.scratch_path.joinpath("script.py") + + script_path.write_text( + textwrap.dedent( + f"""\ + # /// script + # requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*" + # dependencies = [ + # "INITools==0.2", + # "simple==1.0", + # ] + # /// + + print("Hello world from a dummy program") + """ + ) + ) + + result = script.pip_install_local( + "--requirements-from-script", + script_path, + expect_stderr=True, + expect_error=True, + ) + + assert "requires a different Python" in result.stderr, ( + "Script with incompatible requires-python did not fail as expected -- " + + result.stderr + ) + + +def test_script_invalid_TOML(script: PipTestEnvironment) -> None: + """ + Test installing from a script with invalid TOML in its 'script' metadata + """ + + script_path = script.scratch_path.joinpath("script.py") + + script_path.write_text( + textwrap.dedent( + f"""\ + # /// script + # requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*" + # dependencies = [ + # /// + + print("Hello world from a dummy program") + """ + ) + ) + + result = script.pip_install_local( + "--requirements-from-script", + script_path, + expect_stderr=True, + expect_error=True, + ) + + assert "Failed to parse TOML" in result.stderr, ( + "Script with invalid TOML metadata did not fail as expected -- " + result.stderr + ) + + +def test_script_multiple_blocks(script: PipTestEnvironment) -> None: + """ + Test installing from a script with multiple metadata blocks + """ + + script_path = script.scratch_path.joinpath("script.py") + + script_path.write_text( + textwrap.dedent( + f"""\ + # /// script + # requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*" + # dependencies = [ + # "INITools==0.2", + # "simple==1.0", + # ] + # /// + + # /// script + # requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*" + # dependencies = [ + # "INITools==0.2", + # "simple==1.0", + # ] + # /// + + print("Hello world from a dummy program") + """ + ) + ) + + result = script.pip_install_local( + "--requirements-from-script", + script_path, + expect_stderr=True, + expect_error=True, + ) + + assert "Multiple 'script' blocks" in result.stderr, ( + "Script with multiple metadata blocks did not fail as expected -- " + + result.stderr + )