Repository: pdm-project/pdm

Issue:
Allows Specifying the Insertion Position of Extra Arguments Supplied to `pdm run`
## Is your feature request related to a problem? Please describe.

I'm using PDM scripts to both run commands in PDM environment, and also as a shorthand for providing common parameters.

For example, in a Django project, it's common to run commands with `manage.py`. I will use script like

```toml
manage = { cmd = [
    'python', 'manage.py',
] }
```

So that I can use command `pdm manage migrate`, instead of `pdm run python manage.py migrate`. Where the common prefix `python manage.py ...` can be reused.

However, I found that one of my tools (specifically, [`pre-commit`](https://pre-commit.com/)) has commands and arguments in this format:

```shell
pre-commit install --config somewhere/config.yml
pre-commit autoupdate --config somewhere/config.yml
pre-commit run --config somewhere/config.yml
```

The parameters like `--config` must come after commands like `install`. If I create script

```toml
pre-commit = { cmd = [
    'pre-commit', '--config', 'somewhere/config.yml',
] }
```

and execute `pdm run pre-commit install`, the additional argument `install` will always be appeneded to the `cmd` and resulting

```
pre-commit --config somewhere/config.yml install
```

which is invalid for `pre-commit`.

Since the common parts (`pre-commit` and `--config somewhere/config.yml`) of the commands are not a prefix, there is no way for me to create shorthand PDM scripts for them.

**It would be greate if we can specify the insertion position of extra arguments supplied to `pdm run`.**

## Describe the solution you'd like

We can provide an optional placeholder to the `cmd`. e.g.:

```toml
pre-commit = { cmd = [
    'pre-commit', '$args', '--config', 'somewhere/config.yml',
] }
```


Comments:

Patch:
diff --git a/docs/docs/usage/scripts.md b/docs/docs/usage/scripts.md
index 4dd61794..42e66639 100644
--- a/docs/docs/usage/scripts.md
+++ b/docs/docs/usage/scripts.md
@@ -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:
diff --git a/news/1507.feature.md b/news/1507.feature.md
new file mode 100644
index 00000000..b02d5589
--- /dev/null
+++ b/news/1507.feature.md
@@ -0,0 +1,1 @@
+Allows specifying the insertion position of user provided arguments in scripts with the `{args[:default]}` placeholder.
diff --git a/src/pdm/cli/commands/run.py b/src/pdm/cli/commands/run.py
index c169dce7..f6708a35 100644
--- a/src/pdm/cli/commands/run.py
+++ b/src/pdm/cli/commands/run.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import argparse
+import itertools
 import os
 import re
 import shlex
@@ -8,7 +9,7 @@ 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
@@ -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
@@ -216,12 +231,24 @@ class TaskRunner:
         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)
@@ -241,7 +268,6 @@ class TaskRunner:
             ] + list(args)
         elif kind == "composite":
             assert isinstance(value, list)
-            args = list(args)
 
         self.project.core.ui.echo(
             f"Running {task}: [success]{str(args)}[/]",
@@ -249,10 +275,16 @@ class TaskRunner:
             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


Test patch:
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index bf86c192..5385307b 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -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(
@@ -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"] = {
@@ -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"}},