Skip to content

Commit 71d54de

Browse files
committed
Support scripts with inline script metadata as input files
1 parent 5330964 commit 71d54de

File tree

3 files changed

+124
-8
lines changed

3 files changed

+124
-8
lines changed

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: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import itertools
44
import os
5+
import re
56
import shlex
67
import sys
78
import tempfile
@@ -33,6 +34,11 @@
3334
from . import options
3435
from .options import BuildTargetT
3536

37+
if sys.version_info >= (3, 11):
38+
import tomllib
39+
else:
40+
import tomli as tomllib
41+
3642
DEFAULT_REQUIREMENTS_FILES = (
3743
"requirements.in",
3844
"setup.py",
@@ -43,6 +49,10 @@
4349
DEFAULT_REQUIREMENTS_OUTPUT_FILE = "requirements.txt"
4450
METADATA_FILENAMES = frozenset({"setup.py", "setup.cfg", "pyproject.toml"})
4551

52+
INLINE_SCRIPT_METADATA_REGEX = (
53+
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
54+
)
55+
4656

4757
def _determine_linesep(
4858
strategy: str = "preserve", filenames: tuple[str, ...] = ()
@@ -170,7 +180,8 @@ def cli(
170180
) -> None:
171181
"""
172182
Compiles requirements.txt from requirements.in, pyproject.toml, setup.cfg,
173-
or setup.py specs.
183+
or setup.py specs, as well as Python scripts containing inline script
184+
metadata.
174185
"""
175186
if color is not None:
176187
ctx.color = color
@@ -344,14 +355,50 @@ def cli(
344355
)
345356
raise click.BadParameter(msg)
346357

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