From d66153fa85fcffd7a9811cba55a37f57639d8b0a Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 16:24:27 -0400 Subject: [PATCH 01/15] Add --script to install command --- src/pip/_internal/cli/cmdoptions.py | 12 ++++++++++++ src/pip/_internal/commands/install.py | 1 + 2 files changed, 13 insertions(+) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 0b7cff77bdd..c482300bf18 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -428,6 +428,18 @@ 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 script() -> Option: + return Option( + "-s", + "--script", + dest="script", + 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/commands/install.py b/src/pip/_internal/commands/install.py index ad45a2f2a57..a3ae8d47a51 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.script()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) From 0035c4126809eb6f7c38d9c3e643f0673fddc7a0 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 17:53:16 -0400 Subject: [PATCH 02/15] Add failing test for --script --- tests/functional/test_install_script.py | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/functional/test_install_script.py diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py new file mode 100644 index 00000000000..50d54664a86 --- /dev/null +++ b/tests/functional/test_install_script.py @@ -0,0 +1,41 @@ +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 + + # TODO:2024-10-05:snoopj:should this test actually run the script? if so, it should use the dependencies From e62ce80ffbce4460b05b654d6059744b5e251f79 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 18:36:46 -0400 Subject: [PATCH 03/15] Default --script to None --- src/pip/_internal/cli/cmdoptions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index c482300bf18..5915ffcc667 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -434,6 +434,7 @@ def script() -> Option: return Option( "-s", "--script", + default=None, dest="script", metavar="file", help="Install PEP 723 inline dependencies of the given script file. " From 610bc125c816bc3fdb7b02630e004d9a1ae6da2f Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 18:38:58 -0400 Subject: [PATCH 04/15] Add minimum implementation of parsing requirements from inline metadata --- src/pip/_internal/cli/req_command.py | 6 ++++- src/pip/_internal/metadata/pep723.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/pip/_internal/metadata/pep723.py diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 92900f94ff4..0831d39b853 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -17,6 +17,7 @@ from pip._internal.exceptions import CommandError, PreviousBuildDirError from pip._internal.index.collector import LinkCollector from pip._internal.index.package_finder import PackageFinder +from pip._internal.metadata.pep723 import parse_pep723_requirements from pip._internal.models.selection_prefs import SelectionPreferences from pip._internal.models.target_python import TargetPython from pip._internal.network.session import PipSession @@ -268,11 +269,14 @@ def get_requirements( ) requirements.append(req_to_add) + if options.script: + requirements.extend(parse_pep723_requirements(options.script)) + # 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.script): opts = {"name": self.name} if options.find_links: raise CommandError( diff --git a/src/pip/_internal/metadata/pep723.py b/src/pip/_internal/metadata/pep723.py new file mode 100644 index 00000000000..c901cf1790a --- /dev/null +++ b/src/pip/_internal/metadata/pep723.py @@ -0,0 +1,40 @@ +import re +from typing import List, Optional + +from pip._internal.req.req_install import InstallRequirement +from pip._vendor import tomli as tomllib +from pip._vendor.packaging.requirements import Requirement + +REGEX = r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$' + + +def pep723_metadata(scriptfile: str) -> Optional[dict]: + with open(scriptfile, "r") 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}") + + +def parse_pep723_requirements(scriptfile: str) -> List[InstallRequirement]: + md = pep723_metadata(scriptfile) + reqs = [] + + for rq in md.get("dependencies", []): + reqs.append( + InstallRequirement(Requirement(rq), comes_from=None) + ) + + return reqs From 2e612984d650c894f11a90af9e88e3f124b2a20b Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 19:22:52 -0400 Subject: [PATCH 05/15] Issue an error if --script is given multiple times This also lays the groundwork for potentially allowing the argument to be given multiple times, but I don't yet want to think about the additional complexity of multiple possibly-contradictory `requires-python` specifiers. --- src/pip/_internal/cli/cmdoptions.py | 7 ++++--- src/pip/_internal/cli/req_command.py | 9 ++++++--- src/pip/_internal/commands/install.py | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index 5915ffcc667..e305ce5d66f 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -430,12 +430,13 @@ 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 script() -> Option: +def scripts() -> Option: return Option( "-s", "--script", - default=None, - dest="script", + action="append", + default=[], + dest="scripts", metavar="file", help="Install PEP 723 inline dependencies of the given script file. " ) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 0831d39b853..48d9ecc562b 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -269,14 +269,17 @@ def get_requirements( ) requirements.append(req_to_add) - if options.script: - requirements.extend(parse_pep723_requirements(options.script)) + if options.scripts: + if len(options.scripts) > 1: + raise CommandError("--script can only be given once") + + requirements.extend(parse_pep723_requirements(options.scripts[0])) # 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 or options.script): + 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/install.py b/src/pip/_internal/commands/install.py index a3ae8d47a51..ed997a2e80b 100644 --- a/src/pip/_internal/commands/install.py +++ b/src/pip/_internal/commands/install.py @@ -73,7 +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.script()) + self.cmd_opts.add_option(cmdoptions.scripts()) self.cmd_opts.add_option(cmdoptions.no_deps()) self.cmd_opts.add_option(cmdoptions.pre()) From bd0df37bc7cbb5e22cc25b56f615af271815415d Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 19:37:10 -0400 Subject: [PATCH 06/15] Add scripts() to download, wheel subcommands --- src/pip/_internal/commands/download.py | 1 + src/pip/_internal/commands/wheel.py | 1 + 2 files changed, 2 insertions(+) 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/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()) From e6231e9409e8ee6c7f2ec1346d6ff450f8f05bcd Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 20:17:35 -0400 Subject: [PATCH 07/15] Test that --script can only be given once --- tests/functional/test_install_script.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py index 50d54664a86..9870a7aa974 100644 --- a/tests/functional/test_install_script.py +++ b/tests/functional/test_install_script.py @@ -39,3 +39,15 @@ def test_script_file(script: PipTestEnvironment) -> None: assert result.files_created[script.site_packages / fn].dir # TODO:2024-10-05:snoopj:should this test actually run the script? if so, it should use the dependencies + + +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) From 1fd7c087a973413709bb1cc7131f7210ec54d646 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sat, 5 Oct 2024 20:18:04 -0400 Subject: [PATCH 08/15] Remove TODO (I think the answer is 'no') --- tests/functional/test_install_script.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py index 9870a7aa974..533d5d8125f 100644 --- a/tests/functional/test_install_script.py +++ b/tests/functional/test_install_script.py @@ -38,8 +38,6 @@ def test_script_file(script: PipTestEnvironment) -> None: fn = f"{other_lib_name}-{other_lib_version}.dist-info" assert result.files_created[script.site_packages / fn].dir - # TODO:2024-10-05:snoopj:should this test actually run the script? if so, it should use the dependencies - def test_multiple_scripts(script: PipTestEnvironment) -> None: """ From bfa57d47f609eeae7331252dbb6a30b7c3aa276a Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sun, 20 Oct 2024 14:38:33 -0400 Subject: [PATCH 09/15] Add failing test for incompatible requires-python --- tests/functional/test_install_script.py | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py index 533d5d8125f..6eabc8d38e8 100644 --- a/tests/functional/test_install_script.py +++ b/tests/functional/test_install_script.py @@ -1,4 +1,5 @@ import textwrap +import sys import pytest @@ -49,3 +50,32 @@ def test_multiple_scripts(script: PipTestEnvironment) -> None: "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") + this_python_ver = f"{sys.version_info.major}.{sys.version_info.minor}" + script_path.write_text( + textwrap.dedent( + f"""\ + # /// script + # requires-python = ">{this_python_ver}" + # dependencies = [ + # "INITools==0.2", + # "{other_lib_name}<={other_lib_version}", + # ] + # /// + + print("Hello world from a dummy program") + """ + ) + ) + + with pytest.raises(): + result = script.pip("install", "--script", script_path) From 6e0fd8d2841c479046439a1df7fa1dc6e84a9346 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sun, 27 Oct 2024 19:01:16 -0400 Subject: [PATCH 10/15] Correct type annotation of PEP 723 helper --- src/pip/_internal/metadata/pep723.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pip/_internal/metadata/pep723.py b/src/pip/_internal/metadata/pep723.py index c901cf1790a..23c735a9722 100644 --- a/src/pip/_internal/metadata/pep723.py +++ b/src/pip/_internal/metadata/pep723.py @@ -8,7 +8,7 @@ REGEX = r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$' -def pep723_metadata(scriptfile: str) -> Optional[dict]: +def pep723_metadata(scriptfile: str) -> dict: with open(scriptfile, "r") as f: script = f.read() From 0b2b7f46a1c4d046038b647f0fec459dbcaa6b4d Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sun, 27 Oct 2024 20:07:41 -0400 Subject: [PATCH 11/15] Remove PEP 723 requirements helper in favor of direct access --- src/pip/_internal/cli/req_command.py | 11 +++++++++-- src/pip/_internal/metadata/pep723.py | 15 --------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 48d9ecc562b..50f11194c92 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -17,7 +17,7 @@ from pip._internal.exceptions import CommandError, PreviousBuildDirError from pip._internal.index.collector import LinkCollector from pip._internal.index.package_finder import PackageFinder -from pip._internal.metadata.pep723 import parse_pep723_requirements +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 @@ -37,6 +37,7 @@ TempDirectoryTypeRegistry, tempdir_kinds, ) +from pip._vendor.packaging.requirements import Requirement logger = logging.getLogger(__name__) @@ -273,7 +274,13 @@ def get_requirements( if len(options.scripts) > 1: raise CommandError("--script can only be given once") - requirements.extend(parse_pep723_requirements(options.scripts[0])) + script = options.scripts[0] + script_metadata = pep723_metadata(script) + + 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): diff --git a/src/pip/_internal/metadata/pep723.py b/src/pip/_internal/metadata/pep723.py index 23c735a9722..1a5be096ea2 100644 --- a/src/pip/_internal/metadata/pep723.py +++ b/src/pip/_internal/metadata/pep723.py @@ -1,9 +1,6 @@ import re -from typing import List, Optional -from pip._internal.req.req_install import InstallRequirement from pip._vendor import tomli as tomllib -from pip._vendor.packaging.requirements import Requirement REGEX = r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$' @@ -26,15 +23,3 @@ def pep723_metadata(scriptfile: str) -> dict: return tomllib.loads(content) else: raise ValueError(f"File does not contain 'script' metadata: {scriptfile!r}") - - -def parse_pep723_requirements(scriptfile: str) -> List[InstallRequirement]: - md = pep723_metadata(scriptfile) - reqs = [] - - for rq in md.get("dependencies", []): - reqs.append( - InstallRequirement(Requirement(rq), comes_from=None) - ) - - return reqs From 55d304942399dadff83fe610b169df4bb9e4a934 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sun, 27 Oct 2024 20:10:19 -0400 Subject: [PATCH 12/15] Check requires-python specified in script metadata --- src/pip/_internal/cli/req_command.py | 18 +++++++++++++++++- tests/functional/test_install_script.py | 8 ++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index 50f11194c92..d4111dd6561 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -12,9 +12,10 @@ 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 @@ -32,6 +33,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, @@ -277,6 +279,20 @@ def get_requirements( 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) diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py index 6eabc8d38e8..7b3de7635a8 100644 --- a/tests/functional/test_install_script.py +++ b/tests/functional/test_install_script.py @@ -60,12 +60,12 @@ def test_script_file_python_version(script: PipTestEnvironment) -> None: other_lib_name, other_lib_version = "peppercorn", "0.6" script_path = script.scratch_path.joinpath("script.py") - this_python_ver = f"{sys.version_info.major}.{sys.version_info.minor}" + target_python_ver = f"{sys.version_info.major}.{sys.version_info.minor + 1}" script_path.write_text( textwrap.dedent( f"""\ # /// script - # requires-python = ">{this_python_ver}" + # requires-python = ">={target_python_ver}" # dependencies = [ # "INITools==0.2", # "{other_lib_name}<={other_lib_version}", @@ -77,5 +77,5 @@ def test_script_file_python_version(script: PipTestEnvironment) -> None: ) ) - with pytest.raises(): - result = script.pip("install", "--script", script_path) + 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) From 418bc014f007e7ffcb67e3af55a7aa85f10c0b2f Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sun, 27 Oct 2024 20:10:31 -0400 Subject: [PATCH 13/15] Appease the linters --- src/pip/_internal/cli/cmdoptions.py | 2 +- src/pip/_internal/cli/req_command.py | 9 ++++++-- src/pip/_internal/metadata/pep723.py | 16 ++++++------- tests/functional/test_install_script.py | 30 ++++++++++++++++++------- 4 files changed, 38 insertions(+), 19 deletions(-) diff --git a/src/pip/_internal/cli/cmdoptions.py b/src/pip/_internal/cli/cmdoptions.py index e305ce5d66f..89ce6a7a17a 100644 --- a/src/pip/_internal/cli/cmdoptions.py +++ b/src/pip/_internal/cli/cmdoptions.py @@ -438,7 +438,7 @@ def scripts() -> Option: default=[], dest="scripts", metavar="file", - help="Install PEP 723 inline dependencies of the given script file. " + help="Install PEP 723 inline dependencies of the given script file. ", ) diff --git a/src/pip/_internal/cli/req_command.py b/src/pip/_internal/cli/req_command.py index d4111dd6561..294dc9f4068 100644 --- a/src/pip/_internal/cli/req_command.py +++ b/src/pip/_internal/cli/req_command.py @@ -10,12 +10,18 @@ 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, UnsupportedPythonVersion +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 @@ -39,7 +45,6 @@ TempDirectoryTypeRegistry, tempdir_kinds, ) -from pip._vendor.packaging.requirements import Requirement logger = logging.getLogger(__name__) diff --git a/src/pip/_internal/metadata/pep723.py b/src/pip/_internal/metadata/pep723.py index 1a5be096ea2..e2a80345cc1 100644 --- a/src/pip/_internal/metadata/pep723.py +++ b/src/pip/_internal/metadata/pep723.py @@ -2,23 +2,23 @@ from pip._vendor import tomli as tomllib -REGEX = r'(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$' +REGEX = r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$" def pep723_metadata(scriptfile: str) -> dict: - with open(scriptfile, "r") as f: + with open(scriptfile) as f: script = f.read() - name = 'script' + name = "script" matches = list( - filter(lambda m: m.group('type') == name, re.finditer(REGEX, script)) + filter(lambda m: m.group("type") == name, re.finditer(REGEX, script)) ) if len(matches) > 1: - raise ValueError(f'Multiple {name} blocks found') + 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) + 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: diff --git a/tests/functional/test_install_script.py b/tests/functional/test_install_script.py index 7b3de7635a8..df1ea1bf9fb 100644 --- a/tests/functional/test_install_script.py +++ b/tests/functional/test_install_script.py @@ -1,5 +1,5 @@ -import textwrap import sys +import textwrap import pytest @@ -44,12 +44,19 @@ 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) + 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) + assert "ERROR: --script can only be given once" in result.stderr, ( + "multiple script did not fail as expected -- " + result.stderr + ) @pytest.mark.network @@ -77,5 +84,12 @@ def test_script_file_python_version(script: PipTestEnvironment) -> None: ) ) - 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) + 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 + ) From 924859c62d560f51493d257212270a7fb00f7be6 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sun, 27 Oct 2024 20:14:57 -0400 Subject: [PATCH 14/15] Write return annotation correctly --- src/pip/_internal/metadata/pep723.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pip/_internal/metadata/pep723.py b/src/pip/_internal/metadata/pep723.py index e2a80345cc1..8b23df63705 100644 --- a/src/pip/_internal/metadata/pep723.py +++ b/src/pip/_internal/metadata/pep723.py @@ -1,11 +1,12 @@ 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: +def pep723_metadata(scriptfile: str) -> Dict[str, Any]: with open(scriptfile) as f: script = f.read() From cab27869d129fd40fd8adfe5f5200c2270067b29 Mon Sep 17 00:00:00 2001 From: James Gerity Date: Sun, 27 Oct 2024 20:22:14 -0400 Subject: [PATCH 15/15] Add NEWS fragment --- news/12891.feature.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 news/12891.feature.rst 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).