Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 29 additions & 0 deletions docs/usage/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,35 @@ migrate_db = "flask db upgrade"

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

### Overriding Options from the Command Line

!!! tip
Added in 2.29.0.

Some script options can also be set or overridden dynamically when invoking `pdm run`,
which is useful for running the same script against different environments or for
running a single command of a composite script without changing `pyproject.toml`:

```bash
pdm run --env FOO=bar --env-file .env.staging --working-dir subdir start
```

- `--env KEY=VALUE` sets an environment variable for the script. It can be supplied
multiple times and overrides the `env` mapping defined in the script.
- `--env-file FILE` sets the dotenv file to load, overriding the `env_file` option
defined in the script.
- `--working-dir DIR` sets the working directory, overriding the `working_dir` option
defined in the script.

These options take precedence over the values defined in `pyproject.toml`, and they
are also applied to all tasks invoked by a composite script.

!!! note
As with other `pdm run` flags (e.g. `-s/--site-packages`), these options must be
placed before the script name. Everything following the script name is forwarded
to the script as-is, so `pdm run start --env-file .env.staging` would pass
`--env-file .env.staging` to the `start` command instead of PDM.

### Arguments placeholder

By default, all user provided extra arguments are simply appended to the command (or to all the commands for `composite` tasks).
Expand Down
1 change: 1 addition & 0 deletions news/3829.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Extend `pdm run` with `--env`, `--env-file` and `--working-dir` options to set or override the corresponding script options dynamically.
42 changes: 40 additions & 2 deletions src/pdm/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ def __init__(self, project: Project, hooks: HookManager) -> None:
self.global_options = global_options.copy()
self.recreate_env = False
self.hooks = hooks
self.run_options: TaskOptions = {}

def _get_script_env(self, script_file: str) -> BaseEnvironment:
import hashlib
Expand Down Expand Up @@ -262,7 +263,10 @@ def _run_process(

project = self.project
if not shell and args[0].endswith(".py"):
project_env = self._get_script_env(os.path.expanduser(args[0]))
script_file = os.path.expanduser(args[0])
if working_dir and not os.path.isabs(script_file):
script_file = os.path.join(project.root, working_dir, script_file)
project_env = self._get_script_env(script_file)
else:
check_project_file(project)
project_env = project.environment
Expand Down Expand Up @@ -423,6 +427,7 @@ def run(
return 0
if seen is None:
seen = set()
opts = merge_options(opts, self.run_options)
task = self.get_task(command)
if task is not None:
if task.kind == "composite":
Expand Down Expand Up @@ -523,6 +528,25 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
exec.add_argument(
"--recreate", action="store_true", help="Recreate the script environment for self-contained scripts"
)
exec.add_argument(
"--env",
dest="env",
action="append",
metavar="KEY=VALUE",
help="Set environment variables for the script. Can be supplied multiple times",
)
exec.add_argument(
"--env-file",
dest="env_file",
metavar="FILE",
help="Read environment variables from the given dotenv file",
)
exec.add_argument(
"--working-dir",
dest="working_dir",
metavar="DIR",
help="Set the working directory for the script",
)
exec.add_argument("script", nargs="?", help="The command to run")
exec.add_argument(
"args",
Expand All @@ -537,8 +561,22 @@ def get_runner(self, project: Project, hooks: HookManager, options: argparse.Nam
else:
runner = TaskRunner(project, hooks)
runner.recreate_env = options.recreate
run_options: TaskOptions = {}
if options.site_packages:
runner.global_options["site_packages"] = True
run_options["site_packages"] = True
if options.env:
env: dict[str, str] = {}
for item in options.env:
key, sep, value = item.partition("=")
if not sep or not key:
raise PdmUsageError(f"Invalid environment variable: [success]{item}[/], expected KEY=VALUE")
env[key] = value
run_options["env"] = env
if options.env_file:
run_options["env_file"] = options.env_file
if options.working_dir:
run_options["working_dir"] = options.working_dir
Comment thread
frostming marked this conversation as resolved.
runner.run_options = run_options
return runner

def handle(self, project: Project, options: argparse.Namespace) -> None:
Expand Down
91 changes: 91 additions & 0 deletions tests/cli/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1340,3 +1340,94 @@ def test_run_composite_script_verbose(project, pdm):
assert "['python', '-V']" in result.stderr
assert "python -V" not in result.stderr
assert "help" not in result.stderr


def test_run_env_option(project, pdm, capfd):
(project.root / "test_script.py").write_text("import os; print(os.getenv('FOO'), os.getenv('BAR'))")
project.pyproject.settings["scripts"] = {
"test": {"cmd": "python test_script.py", "env": {"FOO": "default", "BAR": "default"}},
}
project.pyproject.write()
capfd.readouterr()
pdm(["run", "--env", "FOO=cli", "test"], strict=True, obj=project)
assert capfd.readouterr()[0].strip() == "cli default"


def test_run_env_option_without_task(project, pdm, capfd):
(project.root / "test_script.py").write_text("import os; print(os.getenv('FOO'))")
project.pyproject.write()
capfd.readouterr()
with cd(project.root):
pdm(["run", "--env", "FOO=cli", "python", "test_script.py"], strict=True, obj=project)
assert capfd.readouterr()[0].strip() == "cli"


def test_run_env_file_option(project, pdm, capfd):
(project.root / "test_script.py").write_text("import os; print(os.getenv('FOO'))")
project.pyproject.settings["scripts"] = {
"test": {"cmd": "python test_script.py", "env_file": ".env"},
}
project.pyproject.write()
(project.root / ".env").write_text("FOO=from-env\n")
(project.root / ".env.staging").write_text("FOO=from-staging\n")
capfd.readouterr()
pdm(["run", "--env-file", ".env.staging", "test"], strict=True, obj=project)
assert capfd.readouterr()[0].strip() == "from-staging"


def test_run_working_dir_option(project, pdm, capfd):
project.root.joinpath("subdir").mkdir()
project.root.joinpath("subdir", "file.text").write_text("Hello world\n")
project.pyproject.settings["scripts"] = {
"test": {"cmd": "cat file.text"},
}
project.pyproject.write()
capfd.readouterr()
pdm(["run", "--working-dir", "subdir", "test"], strict=True, obj=project)
assert capfd.readouterr()[0].strip() == "Hello world"


def test_run_options_override_composite(project, pdm, capfd):
(project.root / "test_script.py").write_text("import os; print(os.getenv('FOO'))")
project.pyproject.settings["scripts"] = {
"test": {"cmd": "python test_script.py", "env": {"FOO": "default"}},
"composite": {"composite": ["test"]},
}
project.pyproject.write()
capfd.readouterr()
pdm(["run", "--env", "FOO=cli", "composite"], strict=True, obj=project)
assert capfd.readouterr()[0].strip() == "cli"


def test_run_env_option_invalid(project, pdm):
project.pyproject.settings["scripts"] = {"test": "true"}
project.pyproject.write()
result = pdm(["run", "--env", "NOEQUALSIGN", "test"], obj=project)
assert result.exit_code == 1
assert "Invalid environment variable" in result.stderr


def test_run_env_option_empty_key(project, pdm):
project.pyproject.settings["scripts"] = {"test": "true"}
project.pyproject.write()
result = pdm(["run", "--env", "=value", "test"], obj=project)
assert result.exit_code == 1
assert "Invalid environment variable" in result.stderr


def test_run_script_in_working_dir_with_inline_metadata(project, pdm, capfd):
project.root.joinpath("subdir").mkdir()
(project.root / "subdir" / "script.py").write_text(
textwrap.dedent(
"""\
# /// script
# requires-python = ">=3.9"
# ///
print("from subdir")
"""
)
)
project.pyproject.write()
capfd.readouterr()
pdm(["run", "--working-dir", "subdir", "script.py"], obj=project, strict=True)
assert capfd.readouterr()[0].strip() == "from subdir"
Loading