Skip to content

Commit

Permalink
feat(scripts): add an optional {args[:defaults]} placeholder for sc…
Browse files Browse the repository at this point in the history
…ript arguments

Fix pdm-project#1507
  • Loading branch information
noirbizarre committed Nov 29, 2022
1 parent 378a170 commit b7064ca
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 7 deletions.
60 changes: 60 additions & 0 deletions docs/docs/usage/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,66 @@ migrate_db = "flask db upgrade"

Besides, inside the tasks, `PDM_PROJECT_ROOT` environment variable will be set to the project root.

### Arguments placeholder

By default, all user provided extra arguments are simply appended to the command (or to all the commands for `composite` tasks).

If you want more control over the user provided extra arguments, you can use the `{args}` placeholder.
It is available for all script types and will be interpolated properly for each:

```toml
[tool.pdm.scripts]
cmd = "echo '--before {args} --after'"
shell = {shell = "echo '--before {args} --after'"}
composite = {composite = ["cmd --something", "shell {args}"]}
```

will produce the following interpolations (those are not real scripts, just here to illustrate the interpolation):

```shell
$ pdm run cmd --user --provided
--before --user --provided --after
$ pdm run cmd
--before --after
$ pdm run shell --user --provided
--before --user --provided --after
$ pdm run shell
--before --after
$ pdm run composite --user --provided
cmd --something
shell --before --user --provided --after
$ pdm run composite
cmd --something
shell --before --after
```

You may optionally provide default values that will be used if no user arguments are provided:

```toml
[tool.pdm.scripts]
test = "echo '--before {args:--default --value} --after'"
```

will produce the following:

```shell
$ pdm run test --user --provided
--before --user --provided --after
$ pdm run test
--before --default --value --after
```

!!! note
As soon a placeholder is detected, arguments are not appended anymore.
This is important for `composite` scripts because if a placeholder
is detected on one of the subtasks, none for the subtasks will have
the arguments appended, you need to explicitly pass the placeholder
to every nested command requiring it.

!!! note
`call` scripts don't support the `{args}` placeholder as they have
access to `sys.argv` directly to handle such complexe cases and more.

## Show the List of Scripts

Use `pdm run --list/-l` to show the list of available script shortcuts:
Expand Down
1 change: 1 addition & 0 deletions news/1507.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allows specifying the insertion position of user provided arguments in scripts with the `{args[:default]}` placeholder.
46 changes: 39 additions & 7 deletions src/pdm/cli/commands/run.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
from __future__ import annotations

import argparse
import itertools
import os
import re
import shlex
import signal
import subprocess
import sys
from types import FrameType
from typing import Any, Callable, Mapping, NamedTuple, Sequence, cast
from typing import Any, Callable, Iterator, Mapping, NamedTuple, Sequence, cast

from pdm import signals, termui
from pdm.cli.actions import PEP582_PATH
Expand Down Expand Up @@ -46,6 +47,20 @@ def exec_opts(*options: TaskOptions | None) -> dict[str, Any]:
)


RE_ARGS_PLACEHOLDER = re.compile(r"{args(?::(?P<default>[^}]*))?}")


def interpolate(script: str, args: Sequence[str]) -> tuple[str, bool]:
"""Interpolate the `{args:[defaults]} placeholder in a string"""

def replace(m: re.Match[str]) -> str:
default = m.group("default") or ""
return " ".join(args) if args else default

interpolated, count = RE_ARGS_PLACEHOLDER.subn(replace, script)
return interpolated, count > 0


class Task(NamedTuple):
kind: str
name: str
Expand Down Expand Up @@ -216,12 +231,24 @@ def _run_task(
kind, _, value, options = task
shell = False
if kind == "cmd":
if not isinstance(value, list):
value = shlex.split(str(value))
args = value + list(args)
if isinstance(value, str):
cmd, interpolated = interpolate(value, args)
value = shlex.split(cmd)
else:
agg = [interpolate(part, args) for part in value]
interpolated = any(row[1] for row in agg)
# In case of multiple default, we need to split the resulting string.
parts: Iterator[list[str]] = (
shlex.split(part) if interpolated else [part]
for part, interpolated in agg
)
# We flatten the nested list to obtain a list of arguments
value = list(itertools.chain(*parts))
args = value if interpolated else [*value, *args]
elif kind == "shell":
assert isinstance(value, str)
args = " ".join([value] + list(args)) # type: ignore
script, interpolated = interpolate(value, args)
args = script if interpolated else " ".join([script, *args])
shell = True
elif kind == "call":
assert isinstance(value, str)
Expand All @@ -241,18 +268,23 @@ def _run_task(
] + list(args)
elif kind == "composite":
assert isinstance(value, list)
args = list(args)

self.project.core.ui.echo(
f"Running {task}: [success]{str(args)}[/]",
err=True,
verbosity=termui.Verbosity.DETAIL,
)
if kind == "composite":
args = list(args)
should_interpolate = any(
(RE_ARGS_PLACEHOLDER.search(script) for script in value)
)
for script in value:
if should_interpolate:
script, _ = interpolate(script, args)
split = shlex.split(script)
cmd = split[0]
subargs = split[1:] + args # type: ignore
subargs = split[1:] + ([] if should_interpolate else args)
code = self.run(cmd, subargs, options)
if code != 0:
return code
Expand Down
158 changes: 158 additions & 0 deletions tests/cli/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,50 @@ def test_run_shell_script(project, invoke):
assert (project.root / "output.txt").read_text().strip() == "hello"


@pytest.mark.parametrize(
"args,expected",
(
pytest.param(["hello"], "ok hello", id="with-args"),
pytest.param([], "ok", id="without-args"),
),
)
def test_run_shell_script_with_args_placeholder(project, invoke, args, expected):
project.pyproject.settings["scripts"] = {
"test_script": {
"shell": "echo ok {args} > output.txt",
"help": "test it won't fail",
}
}
project.pyproject.write()
with cd(project.root):
result = invoke(["run", "test_script", *args], obj=project)
assert result.exit_code == 0
assert (project.root / "output.txt").read_text().strip() == expected


@pytest.mark.parametrize(
"args,expected",
(
pytest.param(["hello"], "hello", id="with-args"),
pytest.param([], "default", id="with-default"),
),
)
def test_run_shell_script_with_args_placeholder_with_default(
project, invoke, args, expected
):
project.pyproject.settings["scripts"] = {
"test_script": {
"shell": "echo {args:default} > output.txt",
"help": "test it won't fail",
}
}
project.pyproject.write()
with cd(project.root):
result = invoke(["run", "test_script", *args], obj=project)
assert result.exit_code == 0
assert (project.root / "output.txt").read_text().strip() == expected


def test_run_call_script(project, invoke):
(project.root / "test_script.py").write_text(
textwrap.dedent(
Expand Down Expand Up @@ -189,6 +233,74 @@ def test_run_script_with_extra_args(project, invoke, capfd):
assert out.splitlines()[-3:] == ["-a", "-b", "-c"]


@pytest.mark.parametrize(
"args,expected",
(
pytest.param(["-a", "-b", "-c"], ["-a", "-b", "-c", "-x"], id="with-args"),
pytest.param([], ["-x"], id="without-args"),
),
)
@pytest.mark.parametrize(
"script",
(
pytest.param("python test_script.py {args} -x", id="as-str"),
pytest.param(["python", "test_script.py", "{args}", "-x"], id="as-list"),
),
)
def test_run_script_with_args_placeholder(
project, invoke, capfd, script, args, expected
):
(project.root / "test_script.py").write_text(
textwrap.dedent(
"""
import sys
print(*sys.argv[1:], sep='\\n')
"""
)
)
project.pyproject.settings["scripts"] = {"test_script": script}
project.pyproject.write()
with cd(project.root):
invoke(["run", "-v", "test_script", *args], obj=project)
out, _ = capfd.readouterr()
assert out.strip().splitlines()[1:] == expected


@pytest.mark.parametrize(
"args,expected",
(
pytest.param(["-a", "-b", "-c"], ["-a", "-b", "-c", "-x"], id="with-args"),
pytest.param([], ["--default", "--value", "-x"], id="default"),
),
)
@pytest.mark.parametrize(
"script",
(
pytest.param("python test_script.py {args:--default --value} -x", id="as-str"),
pytest.param(
["python", "test_script.py", "{args:--default --value}", "-x"], id="as-list"
),
),
)
def test_run_script_with_args_placeholder_with_default(
project, invoke, capfd, script, args, expected
):
(project.root / "test_script.py").write_text(
textwrap.dedent(
"""
import sys
print(*sys.argv[1:], sep='\\n')
"""
)
)
project.pyproject.settings["scripts"] = {"test_script": script}
project.pyproject.write()
with cd(project.root):
invoke(["run", "-v", "test_script", *args], obj=project)
out, _ = capfd.readouterr()
assert out.strip().splitlines()[1:] == expected


def test_run_expand_env_vars(project, invoke, capfd, monkeypatch):
(project.root / "test_script.py").write_text("import os; print(os.getenv('FOO'))")
project.pyproject.settings["scripts"] = {
Expand Down Expand Up @@ -488,6 +600,52 @@ def test_composite_can_pass_parameters(project, invoke, capfd, _args):
assert "Post-Test CALLED" in out


@pytest.mark.parametrize(
"args,expected",
(
pytest.param(["-a"], "-a, ", id="with-args"),
pytest.param([], "", id="without-args"),
),
)
def test_composite_only_pass_parameters_to_subtasks_with_args(
project, invoke, capfd, _args, args, expected
):
project.pyproject.settings["scripts"] = {
"test": {"composite": ["first", "second {args} key=value"]},
"first": "python args.py First",
"second": "python args.py Second",
}
project.pyproject.write()
capfd.readouterr()
invoke(["run", "-v", "test", *args], strict=True, obj=project)
out, _ = capfd.readouterr()
assert "First CALLED" in out
assert f"Second CALLED with {expected}key=value" in out


@pytest.mark.parametrize(
"args,expected",
(
pytest.param(["-a"], "-a", id="with-args"),
pytest.param([], "--default", id="default"),
),
)
def test_composite_only_pass_parameters_to_subtasks_with_args_with_default(
project, invoke, capfd, _args, args, expected
):
project.pyproject.settings["scripts"] = {
"test": {"composite": ["first", "second {args:--default} key=value"]},
"first": "python args.py First",
"second": "python args.py Second",
}
project.pyproject.write()
capfd.readouterr()
invoke(["run", "-v", "test", *args], strict=True, obj=project)
out, _ = capfd.readouterr()
assert "First CALLED" in out
assert f"Second CALLED with {expected}, key=value" in out


def test_composite_hooks_inherit_env(project, invoke, capfd, _echo):
project.pyproject.settings["scripts"] = {
"pre_task": {"cmd": "python echo.py Pre-Task VAR", "env": {"VAR": "42"}},
Expand Down

0 comments on commit b7064ca

Please sign in to comment.