Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
239f181
Add --script to install command
SnoopJ Oct 5, 2024
b2a8ff6
Add failing test for --script
SnoopJ Oct 5, 2024
4a7eeb1
Default --script to None
SnoopJ Oct 5, 2024
4aba9b8
Add minimum implementation of parsing requirements from inline metadata
SnoopJ Oct 5, 2024
916e4c0
Issue an error if --script is given multiple times
SnoopJ Oct 5, 2024
0697ec2
Add scripts() to download, wheel subcommands
SnoopJ Oct 5, 2024
517c636
Test that --script can only be given once
SnoopJ Oct 6, 2024
866113b
Remove TODO (I think the answer is 'no')
SnoopJ Oct 6, 2024
c88ffba
Add failing test for incompatible requires-python
SnoopJ Oct 20, 2024
26868f6
Correct type annotation of PEP 723 helper
SnoopJ Oct 27, 2024
c7a0656
Remove PEP 723 requirements helper in favor of direct access
SnoopJ Oct 28, 2024
2a2efb4
Check requires-python specified in script metadata
SnoopJ Oct 28, 2024
9e52c31
Appease the linters
SnoopJ Oct 28, 2024
eb5cc10
Write return annotation correctly
SnoopJ Oct 28, 2024
62702db
Add NEWS fragment
SnoopJ Oct 28, 2024
aa6c8a2
Change --script to --requirements-from-script
SnoopJ Sep 11, 2025
5961c4e
Replace --script usage in dedicated PEP 723 tests, fix INITools namin…
SnoopJ Sep 12, 2025
004f144
Add --requirements-from-scripts to lock command
SnoopJ Sep 12, 2025
0958182
Add extra layer of backslash escaping to match test stderr on Windows
SnoopJ Sep 12, 2025
a49fcea
Remove obsolete TODO
SnoopJ Sep 12, 2025
839494b
Change typing.Dict[] to dict[] to appease ruff-check
SnoopJ Sep 12, 2025
d534187
Adjust test requires-python to 'not this version'
SnoopJ Sep 12, 2025
382f9b2
Simplify test of expected error message
SnoopJ Sep 12, 2025
0bfe3b8
Merge branch 'main' into feature/gh12891-inline-metadata
SnoopJ Nov 7, 2025
3cec1f5
Fix syntax error
SnoopJ Nov 7, 2025
28f02da
Remove comment put in place for sake of review
SnoopJ Nov 22, 2025
7a0d53e
Use 'name' in error
SnoopJ Nov 22, 2025
33e711a
Use CommandError for PEP 723 parsing failures
SnoopJ Nov 22, 2025
003a443
Avoid network use by tests, simplify assertions
SnoopJ Nov 22, 2025
126b6ea
Refine requirements from script metadata
SnoopJ Nov 22, 2025
69011ea
Remove unused import
SnoopJ Nov 22, 2025
9cc1fec
Remove short option for PEP 723 requirements
SnoopJ Nov 22, 2025
f42ae07
Move PEP 723 utilities to req
SnoopJ Nov 22, 2025
f459c7a
Add tests for failure modes
SnoopJ Nov 22, 2025
e3e85d3
Fixup reST in NEWS entry
SnoopJ Nov 22, 2025
8fb769e
Fix backtick syntax in news/12891.feature.rst
ichard26 Nov 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions news/12891.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Support installing dependencies declared with inline script metadata
(:pep:`723`) with `--requirements-from-script`.
12 changes: 12 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 41 additions & 1 deletion src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions src/pip/_internal/commands/wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
41 changes: 41 additions & 0 deletions src/pip/_internal/req/pep723.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import re
from typing import Any

from pip._vendor import tomli as tomllib

REGEX = r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\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
159 changes: 159 additions & 0 deletions tests/functional/test_install_script.py
Original file line number Diff line number Diff line change
@@ -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
)