Skip to content

Commit 36987b0

Browse files
SnoopJpfmooreichard26
authored
Support installing requirements from inline script metadata, à la PEP 723 (#13052)
Adds --requirements-from-script to install requirements declared within inline script metadata (PEP 723). The slightly unwieldy name reflects that pip will also check the requires-python metadata before installing any dependencies. A short option can be added later if people ask for it. --------- Co-authored-by: Paul Moore <[email protected]> Co-authored-by: Richard Si <[email protected]>
1 parent b35182d commit 36987b0

File tree

9 files changed

+259
-1
lines changed

9 files changed

+259
-1
lines changed

news/12891.feature.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Support installing dependencies declared with inline script metadata
2+
(:pep:`723`) with ``--requirements-from-script``.

src/pip/_internal/cli/cmdoptions.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,18 @@ def requirements() -> Option:
479479
)
480480

481481

482+
def requirements_from_scripts() -> Option:
483+
return Option(
484+
"--requirements-from-script",
485+
action="append",
486+
default=[],
487+
dest="requirements_from_scripts",
488+
metavar="file",
489+
help="Install dependencies of the given script file"
490+
"as defined by PEP 723 inline metadata. ",
491+
)
492+
493+
482494
def editable() -> Option:
483495
return Option(
484496
"-e",

src/pip/_internal/cli/req_command.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@
1616
from pip._internal.build_env import SubprocessBuildEnvironmentInstaller
1717
from pip._internal.cache import WheelCache
1818
from pip._internal.cli import cmdoptions
19+
from pip._internal.cli.cmdoptions import make_target_python
1920
from pip._internal.cli.index_command import IndexGroupCommand
2021
from pip._internal.cli.index_command import SessionCommandMixin as SessionCommandMixin
21-
from pip._internal.exceptions import CommandError, PreviousBuildDirError
22+
from pip._internal.exceptions import (
23+
CommandError,
24+
PreviousBuildDirError,
25+
UnsupportedPythonVersion,
26+
)
2227
from pip._internal.index.collector import LinkCollector
2328
from pip._internal.index.package_finder import PackageFinder
2429
from pip._internal.models.selection_prefs import SelectionPreferences
@@ -32,10 +37,12 @@
3237
install_req_from_parsed_requirement,
3338
install_req_from_req_string,
3439
)
40+
from pip._internal.req.pep723 import PEP723Exception, pep723_metadata
3541
from pip._internal.req.req_dependency_group import parse_dependency_groups
3642
from pip._internal.req.req_file import parse_requirements
3743
from pip._internal.req.req_install import InstallRequirement
3844
from pip._internal.resolution.base import BaseResolver
45+
from pip._internal.utils.packaging import check_requires_python
3946
from pip._internal.utils.temp_dir import (
4047
TempDirectory,
4148
TempDirectoryTypeRegistry,
@@ -305,6 +312,38 @@ def get_requirements(
305312
)
306313
requirements.append(req_to_add)
307314

315+
if options.requirements_from_scripts:
316+
if len(options.requirements_from_scripts) > 1:
317+
raise CommandError("--requirements-from-script can only be given once")
318+
319+
script = options.requirements_from_scripts[0]
320+
try:
321+
script_metadata = pep723_metadata(script)
322+
except PEP723Exception as exc:
323+
raise CommandError(exc.msg)
324+
325+
script_requires_python = script_metadata.get("requires-python", "")
326+
327+
if script_requires_python and not options.ignore_requires_python:
328+
target_python = make_target_python(options)
329+
330+
if not check_requires_python(
331+
requires_python=script_requires_python,
332+
version_info=target_python.py_version_info,
333+
):
334+
raise UnsupportedPythonVersion(
335+
f"Script {script!r} requires a different Python: "
336+
f"{target_python.py_version} not in {script_requires_python!r}"
337+
)
338+
339+
for req in script_metadata.get("dependencies", []):
340+
req_to_add = install_req_from_req_string(
341+
req,
342+
isolated=options.isolated_mode,
343+
user_supplied=True,
344+
)
345+
requirements.append(req_to_add)
346+
308347
# If any requirement has hash options, enable hash checking.
309348
if any(req.has_hash_options for req in requirements):
310349
options.require_hashes = True
@@ -314,6 +353,7 @@ def get_requirements(
314353
or options.editables
315354
or options.requirements
316355
or options.dependency_groups
356+
or options.requirements_from_scripts
317357
):
318358
opts = {"name": self.name}
319359
if options.find_links:

src/pip/_internal/commands/download.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def add_options(self) -> None:
3737
self.cmd_opts.add_option(cmdoptions.constraints())
3838
self.cmd_opts.add_option(cmdoptions.build_constraints())
3939
self.cmd_opts.add_option(cmdoptions.requirements())
40+
self.cmd_opts.add_option(cmdoptions.requirements_from_scripts())
4041
self.cmd_opts.add_option(cmdoptions.no_deps())
4142
self.cmd_opts.add_option(cmdoptions.no_binary())
4243
self.cmd_opts.add_option(cmdoptions.only_binary())

src/pip/_internal/commands/install.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def add_options(self) -> None:
8787
self.cmd_opts.add_option(cmdoptions.requirements())
8888
self.cmd_opts.add_option(cmdoptions.constraints())
8989
self.cmd_opts.add_option(cmdoptions.build_constraints())
90+
self.cmd_opts.add_option(cmdoptions.requirements_from_scripts())
9091
self.cmd_opts.add_option(cmdoptions.no_deps())
9192
self.cmd_opts.add_option(cmdoptions.pre())
9293

src/pip/_internal/commands/lock.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def add_options(self) -> None:
5555
)
5656
)
5757
self.cmd_opts.add_option(cmdoptions.requirements())
58+
self.cmd_opts.add_option(cmdoptions.requirements_from_scripts())
5859
self.cmd_opts.add_option(cmdoptions.constraints())
5960
self.cmd_opts.add_option(cmdoptions.build_constraints())
6061
self.cmd_opts.add_option(cmdoptions.no_deps())

src/pip/_internal/commands/wheel.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def add_options(self) -> None:
6161
self.cmd_opts.add_option(cmdoptions.build_constraints())
6262
self.cmd_opts.add_option(cmdoptions.editable())
6363
self.cmd_opts.add_option(cmdoptions.requirements())
64+
self.cmd_opts.add_option(cmdoptions.requirements_from_scripts())
6465
self.cmd_opts.add_option(cmdoptions.src())
6566
self.cmd_opts.add_option(cmdoptions.ignore_requires_python())
6667
self.cmd_opts.add_option(cmdoptions.no_deps())

src/pip/_internal/req/pep723.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import re
2+
from typing import Any
3+
4+
from pip._vendor import tomli as tomllib
5+
6+
REGEX = r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
7+
8+
9+
class PEP723Exception(ValueError):
10+
"""Raised to indicate a problem when parsing PEP 723 metadata from a script"""
11+
12+
def __init__(self, msg: str) -> None:
13+
self.msg = msg
14+
15+
16+
def pep723_metadata(scriptfile: str) -> dict[str, Any]:
17+
with open(scriptfile) as f:
18+
script = f.read()
19+
20+
name = "script"
21+
matches = list(
22+
filter(lambda m: m.group("type") == name, re.finditer(REGEX, script))
23+
)
24+
25+
if len(matches) > 1:
26+
raise PEP723Exception(f"Multiple {name!r} blocks found in {scriptfile!r}")
27+
elif len(matches) == 1:
28+
content = "".join(
29+
line[2:] if line.startswith("# ") else line[1:]
30+
for line in matches[0].group("content").splitlines(keepends=True)
31+
)
32+
try:
33+
metadata = tomllib.loads(content)
34+
except Exception as exc:
35+
raise PEP723Exception(f"Failed to parse TOML in {scriptfile!r}") from exc
36+
else:
37+
raise PEP723Exception(
38+
f"File does not contain {name!r} metadata: {scriptfile!r}"
39+
)
40+
41+
return metadata
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import sys
2+
import textwrap
3+
4+
from tests.lib import PipTestEnvironment
5+
6+
7+
def test_script_file(script: PipTestEnvironment) -> None:
8+
"""
9+
Test installing from a script with inline metadata (PEP 723).
10+
"""
11+
12+
script_path = script.scratch_path.joinpath("script.py")
13+
script_path.write_text(
14+
textwrap.dedent(
15+
"""\
16+
# /// script
17+
# dependencies = [
18+
# "INITools==0.2",
19+
# "simple==1.0",
20+
# ]
21+
# ///
22+
23+
print("Hello world from a dummy program")
24+
"""
25+
)
26+
)
27+
script.pip_install_local("--requirements-from-script", script_path)
28+
script.assert_installed(initools="0.2", simple="1.0")
29+
30+
31+
def test_multiple_scripts(script: PipTestEnvironment) -> None:
32+
"""
33+
Test that --requirements-from-script can only be given once in an install command.
34+
"""
35+
result = script.pip(
36+
"install",
37+
"--requirements-from-script",
38+
"does_not_exist.py",
39+
"--requirements-from-script",
40+
"also_does_not_exist.py",
41+
allow_stderr_error=True,
42+
expect_error=True,
43+
)
44+
45+
assert (
46+
"ERROR: --requirements-from-script can only be given once" in result.stderr
47+
), ("multiple script did not fail as expected -- " + result.stderr)
48+
49+
50+
def test_script_file_python_version(script: PipTestEnvironment) -> None:
51+
"""
52+
Test installing from a script with an incompatible `requires-python`
53+
"""
54+
55+
script_path = script.scratch_path.joinpath("script.py")
56+
57+
script_path.write_text(
58+
textwrap.dedent(
59+
f"""\
60+
# /// script
61+
# requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*"
62+
# dependencies = [
63+
# "INITools==0.2",
64+
# "simple==1.0",
65+
# ]
66+
# ///
67+
68+
print("Hello world from a dummy program")
69+
"""
70+
)
71+
)
72+
73+
result = script.pip_install_local(
74+
"--requirements-from-script",
75+
script_path,
76+
expect_stderr=True,
77+
expect_error=True,
78+
)
79+
80+
assert "requires a different Python" in result.stderr, (
81+
"Script with incompatible requires-python did not fail as expected -- "
82+
+ result.stderr
83+
)
84+
85+
86+
def test_script_invalid_TOML(script: PipTestEnvironment) -> None:
87+
"""
88+
Test installing from a script with invalid TOML in its 'script' metadata
89+
"""
90+
91+
script_path = script.scratch_path.joinpath("script.py")
92+
93+
script_path.write_text(
94+
textwrap.dedent(
95+
f"""\
96+
# /// script
97+
# requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*"
98+
# dependencies = [
99+
# ///
100+
101+
print("Hello world from a dummy program")
102+
"""
103+
)
104+
)
105+
106+
result = script.pip_install_local(
107+
"--requirements-from-script",
108+
script_path,
109+
expect_stderr=True,
110+
expect_error=True,
111+
)
112+
113+
assert "Failed to parse TOML" in result.stderr, (
114+
"Script with invalid TOML metadata did not fail as expected -- " + result.stderr
115+
)
116+
117+
118+
def test_script_multiple_blocks(script: PipTestEnvironment) -> None:
119+
"""
120+
Test installing from a script with multiple metadata blocks
121+
"""
122+
123+
script_path = script.scratch_path.joinpath("script.py")
124+
125+
script_path.write_text(
126+
textwrap.dedent(
127+
f"""\
128+
# /// script
129+
# requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*"
130+
# dependencies = [
131+
# "INITools==0.2",
132+
# "simple==1.0",
133+
# ]
134+
# ///
135+
136+
# /// script
137+
# requires-python = "!={sys.version_info.major}.{sys.version_info.minor}.*"
138+
# dependencies = [
139+
# "INITools==0.2",
140+
# "simple==1.0",
141+
# ]
142+
# ///
143+
144+
print("Hello world from a dummy program")
145+
"""
146+
)
147+
)
148+
149+
result = script.pip_install_local(
150+
"--requirements-from-script",
151+
script_path,
152+
expect_stderr=True,
153+
expect_error=True,
154+
)
155+
156+
assert "Multiple 'script' blocks" in result.stderr, (
157+
"Script with multiple metadata blocks did not fail as expected -- "
158+
+ result.stderr
159+
)

0 commit comments

Comments
 (0)