Skip to content

Commit 2f914a1

Browse files
committed
Support scripts with inline script metadata as input files
1 parent 5330964 commit 2f914a1

3 files changed

Lines changed: 120 additions & 8 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ project's virtual environment.
3232

3333
The `pip-compile` command lets you compile a `requirements.txt` file from
3434
your dependencies, specified in either `pyproject.toml`, `setup.cfg`,
35-
`setup.py`, or `requirements.in`.
35+
`setup.py`, `requirements.in`, or pure-Python scripts containing
36+
[inline script metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/).
3637

3738
Run it with `pip-compile` or `python -m piptools compile` (or
3839
`pipx run --spec pip-tools pip-compile` if `pipx` was installed with the

piptools/scripts/compile.py

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
import itertools
44
import os
5+
import re
56
import shlex
67
import sys
78
import tempfile
9+
import tomllib
810
from pathlib import Path
911
from typing import IO, Any, BinaryIO, cast
1012

@@ -43,6 +45,10 @@
4345
DEFAULT_REQUIREMENTS_OUTPUT_FILE = "requirements.txt"
4446
METADATA_FILENAMES = frozenset({"setup.py", "setup.cfg", "pyproject.toml"})
4547

48+
INLINE_SCRIPT_METADATA_REGEX = (
49+
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
50+
)
51+
4652

4753
def _determine_linesep(
4854
strategy: str = "preserve", filenames: tuple[str, ...] = ()
@@ -170,7 +176,8 @@ def cli(
170176
) -> None:
171177
"""
172178
Compiles requirements.txt from requirements.in, pyproject.toml, setup.cfg,
173-
or setup.py specs.
179+
or setup.py specs, as well as Python scripts containing inline script
180+
metadata.
174181
"""
175182
if color is not None:
176183
ctx.color = color
@@ -344,14 +351,50 @@ def cli(
344351
)
345352
raise click.BadParameter(msg)
346353

347-
if src_file == "-":
348-
# pip requires filenames and not files. Since we want to support
349-
# piping from stdin, we need to briefly save the input from stdin
350-
# to a temporary file and have pip read that. also used for
354+
if src_file == "-" or (
355+
os.path.basename(src_file).endswith(".py") and not is_setup_file
356+
):
357+
# pip requires filenames and not files. Since we want to support
358+
# piping from stdin, and inline script metadadat within Python
359+
# scripts, we need to briefly save the input or extracted script
360+
# dependencies to a temporary file and have pip read that. Also used for
351361
# reading requirements from install_requires in setup.py.
362+
if os.path.basename(src_file).endswith(".py"):
363+
# Probably contains inline script metadata
364+
with open(src_file, encoding="utf-8") as f:
365+
script = f.read()
366+
name = "script"
367+
matches = list(
368+
filter(
369+
lambda m: m.group("type") == name,
370+
re.finditer(INLINE_SCRIPT_METADATA_REGEX, script),
371+
)
372+
)
373+
if len(matches) > 1:
374+
raise ValueError(f"Multiple {name} blocks found")
375+
elif len(matches) == 1:
376+
content = "".join(
377+
line[2:] if line.startswith("# ") else line[1:]
378+
for line in matches[0]
379+
.group("content")
380+
.splitlines(keepends=True)
381+
)
382+
metadata = tomllib.loads(content)
383+
reqs_str = metadata.get("dependencies", [])
384+
tmpfile = tempfile.NamedTemporaryFile(mode="wt", delete=False)
385+
input_reqs = "\n".join(reqs_str)
386+
comes_from = (
387+
f"{os.path.basename(src_file)} (inline script metadata)"
388+
)
389+
else:
390+
raise PipToolsError(
391+
"Input script does not contain valid inline script metadata!"
392+
)
393+
else:
394+
input_reqs = sys.stdin.read()
395+
comes_from = "-r -"
352396
tmpfile = tempfile.NamedTemporaryFile(mode="wt", delete=False)
353-
tmpfile.write(sys.stdin.read())
354-
comes_from = "-r -"
397+
tmpfile.write(input_reqs)
355398
tmpfile.flush()
356399
reqs = list(
357400
parse_requirements(

tests/test_cli_compile.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from pip._vendor.packaging.version import Version
1818

1919
from piptools.build import ProjectMetadata
20+
from piptools.exceptions import PipToolsError
2021
from piptools.scripts.compile import cli
2122
from piptools.utils import (
2223
COMPILE_EXCLUDE_OPTIONS,
@@ -3771,3 +3772,70 @@ def test_stdout_should_not_be_read_when_stdin_is_not_a_plain_file(
37713772
out = runner.invoke(cli, [req_in.as_posix(), "--output-file", fifo.as_posix()])
37723773

37733774
assert out.exit_code == 0, out
3775+
3776+
3777+
def test_compile_inline_script_metadata(runner, tmp_path, current_resolver):
3778+
(tmp_path / "script.py").write_text(
3779+
dedent(
3780+
"""
3781+
# /// script
3782+
# dependencies = [
3783+
# "small-fake-with-deps",
3784+
# ]
3785+
# ///
3786+
"""
3787+
)
3788+
)
3789+
out = runner.invoke(
3790+
cli,
3791+
[
3792+
"--no-build-isolation",
3793+
"--no-header",
3794+
"--no-emit-options",
3795+
"--find-links",
3796+
os.fspath(MINIMAL_WHEELS_PATH),
3797+
os.fspath(tmp_path / "script.py"),
3798+
"--output-file",
3799+
"-",
3800+
],
3801+
)
3802+
expected = r"""small-fake-a==0.1
3803+
# via small-fake-with-deps
3804+
small-fake-with-deps==0.1
3805+
# via script.py (inline script metadata)
3806+
"""
3807+
assert out.exit_code == 0
3808+
assert expected == out.stdout
3809+
3810+
3811+
def test_compile_inline_script_metadata_invalid(runner, tmp_path, current_resolver):
3812+
(tmp_path / "script.py").write_text(
3813+
dedent(
3814+
"""
3815+
# /// invalid-name
3816+
# dependencies = [
3817+
# "small-fake-a",
3818+
# "small-fake-b",
3819+
# ]
3820+
# ///
3821+
"""
3822+
)
3823+
)
3824+
with pytest.raises(
3825+
PipToolsError, match="does not contain valid inline script metadata"
3826+
):
3827+
runner.invoke(
3828+
cli,
3829+
[
3830+
"--no-build-isolation",
3831+
"--no-header",
3832+
"--no-annotate",
3833+
"--no-emit-options",
3834+
"--find-links",
3835+
os.fspath(MINIMAL_WHEELS_PATH),
3836+
os.fspath(tmp_path / "script.py"),
3837+
"--output-file",
3838+
"-",
3839+
],
3840+
catch_exceptions=False,
3841+
)

0 commit comments

Comments
 (0)