Skip to content
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
25 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
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).
13 changes: 13 additions & 0 deletions src/pip/_internal/cli/cmdoptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,19 @@ def requirements() -> Option:
)


def requirements_from_scripts() -> Option:
return Option(
"-s",
"--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
38 changes: 37 additions & 1 deletion src/pip/_internal/cli/req_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@
from optparse import Values
from typing import Any

from pip._vendor.packaging.requirements import Requirement

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.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
Expand All @@ -35,6 +43,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,
Expand Down Expand Up @@ -285,6 +294,32 @@ 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]
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( # noqa: PERF401
InstallRequirement(Requirement(req), comes_from=None)
)
Comment on lines +317 to +341
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When first submitting this PR, I originally commented on this code:

It feels ungraceful to put all of this logic here but I'm not really sure how I would plumb it into a separate function. Suggestions welcome

I'm not sure I feel as strongly about it now, though. Worth highlighting, though.


# 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 @@ -294,6 +329,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 @@ 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.requirements_from_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())
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 @@ 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.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 @@ -58,6 +58,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.no_deps())
self.cmd_opts.add_option(cmdoptions.pre())
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 @@ -62,6 +62,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.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
26 changes: 26 additions & 0 deletions src/pip/_internal/metadata/pep723.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import re
from typing import Any, Dict

from pip._vendor import tomli as tomllib

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

import pytest

from tests.lib import PipTestEnvironment


@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", "--requirements-from-script", script_path)

# NOTE:2024-10-05:snoopj:assertions same as in test_requirements_file
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not meant to retain this comment but I figured I'd call it out for review that this test is more or less a duplicate of its cousin in terms of side effects, in case that's not appropriate.

Suggested change
# 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 --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)


@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",
"--requirements-from-script",
script_path,
expect_stderr=True,
expect_error=True,
)

if sys.platform == "win32":
# Special case: result.stderr contains an extra layer of backslash
# escaping, transform our path to match
script_path_str = str(script_path).replace("\\", "\\\\")
else:
script_path_str = str(script_path)

assert (
f"ERROR: Script '{script_path_str}' requires a different Python"
in result.stderr
), (
"Script with incompatible requires-python did not fail as expected -- "
+ result.stderr
)
Loading