Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Add an option for PDM's scripts to set the current working directory #2694

Merged
merged 1 commit into from
Mar 15, 2024
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
16 changes: 16 additions & 0 deletions docs/docs/usage/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ all = {composite = ["lint", "test"]}

Running `pdm run all` will run `lint` first and then `test` if `lint` succeeded.

+++ 2.13.0

To override the default behavior and continue the execution of the remaining
scripts after a failure, set the `keep_going` option to `true`:

Expand Down Expand Up @@ -179,6 +181,20 @@ start.env_file.override = ".env"
!!! note
A dotenv file specified on a composite task level will override those defined by called tasks.

### `working_dir`

+++ 2.13.0

You can set the current working directory for the script:

```toml
[tool.pdm.scripts]
start.cmd = "flask run -p 54321"
start.working_dir = "subdir"
```

Relative paths are resolved against the project root.

### `site_packages`

To make sure the running environment is properly isolated from the outer Python interpreter,
Expand Down
1 change: 1 addition & 0 deletions news/2620.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add an option `working_dir` for PDM's scripts to set the current working directory.
21 changes: 7 additions & 14 deletions src/pdm/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@ class TaskOptions(TypedDict, total=False):
help: str
keep_going: bool
site_packages: bool
working_dir: str


def exec_opts(*options: TaskOptions | None) -> dict[str, Any]:
return dict(
env={k: v for opts in options if opts for k, v in opts.get("env", {}).items()},
env={k: v for opts in options if opts for k, v in opts.get("env", {}).items()} or None,
**{k: v for opts in options if opts for k, v in opts.items() if k not in ("env", "help")},
)

Expand Down Expand Up @@ -104,7 +105,7 @@ class TaskRunner:
"""The task runner for pdm project"""

TYPES = ("cmd", "shell", "call", "composite")
OPTIONS = ("env", "env_file", "help", "keep_going", "site_packages")
OPTIONS = ("env", "env_file", "help", "keep_going", "working_dir", "site_packages")

def __init__(self, project: Project, hooks: HookManager) -> None:
self.project = project
Expand Down Expand Up @@ -159,6 +160,7 @@ def _run_process(
site_packages: bool = False,
env: Mapping[str, str] | None = None,
env_file: EnvFileOptions | str | None = None,
working_dir: str | None = None,
) -> int:
"""Run command in a subprocess and return the exit code."""
project = self.project
Expand Down Expand Up @@ -213,7 +215,7 @@ def _run_process(
# Don't load system site-packages
process_env["NO_SITE_PACKAGES"] = "1"

cwd = project.root if chdir else None
cwd = (project.root / working_dir) if working_dir else project.root if chdir else None

def forward_signal(signum: int, frame: FrameType | None) -> None:
if sys.platform == "win32" and signum == signal.SIGINT:
Expand Down Expand Up @@ -285,12 +287,7 @@ def run_task(self, task: Task, args: Sequence[str] = (), opts: TaskOptions | Non
return code
composite_code = code
return composite_code
return self._run_process(
args,
chdir=True,
shell=shell,
**exec_opts(self.global_options, options, opts),
)
return self._run_process(args, chdir=True, shell=shell, **exec_opts(self.global_options, options, opts))

def run(self, command: str, args: list[str], opts: TaskOptions | None = None, chdir: bool = False) -> int:
if command in self.hooks.skip:
Expand All @@ -312,11 +309,7 @@ def run(self, command: str, args: list[str], opts: TaskOptions | None = None, ch
self.hooks.try_emit("post_script", script=command, args=args)
return code
else:
return self._run_process(
[command, *args],
chdir=chdir,
**exec_opts(self.global_options, opts),
)
return self._run_process([command, *args], chdir=chdir, **exec_opts(self.global_options, opts))

def show_list(self) -> None:
if not self.project.scripts:
Expand Down
21 changes: 9 additions & 12 deletions src/pdm/termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import warnings
from typing import TYPE_CHECKING

import rich
from rich.box import ROUNDED
from rich.console import Console
from rich.progress import Progress, ProgressColumn
Expand Down Expand Up @@ -36,21 +37,21 @@
"info": "blue",
"req": "bold green",
}
_console = Console(highlight=False, theme=Theme(DEFAULT_THEME))
rich.reconfigure(highlight=False, theme=Theme(DEFAULT_THEME))
_err_console = Console(stderr=True, theme=Theme(DEFAULT_THEME))


def is_interactive(console: Console | None = None) -> bool:
"""Check if the terminal is run under interactive mode"""
if console is None:
console = _console
console = rich.get_console()
return console.is_interactive


def is_legacy_windows(console: Console | None = None) -> bool:
"""Legacy Windows renderer may have problem rendering emojis"""
if console is None:
console = _console
console = rich.get_console()
return console.legacy_windows


Expand All @@ -61,6 +62,7 @@ def style(text: str, *args: str, style: str | None = None, **kwargs: Any) -> str
:param style: rich style to apply to whole string
:return: string containing ansi codes
"""
_console = rich.get_console()
if _console.legacy_windows or not _console.is_terminal: # pragma: no cover
return text
with _console.capture() as capture:
Expand Down Expand Up @@ -176,7 +178,7 @@ def set_theme(self, theme: Theme) -> None:

:param theme: dict of theme
"""
_console.push_theme(theme)
rich.get_console().push_theme(theme)
_err_console.push_theme(theme)

def echo(
Expand All @@ -193,7 +195,7 @@ def echo(
:param verbosity: verbosity level, defaults to QUIET.
"""
if self.verbosity >= verbosity:
console = _err_console if err else _console
console = _err_console if err else rich.get_console()
if not console.is_interactive:
kwargs.setdefault("crop", False)
kwargs.setdefault("overflow", "ignore")
Expand Down Expand Up @@ -223,7 +225,7 @@ def display_columns(self, rows: Sequence[Sequence[str]], header: list[str] | Non
for row in rows:
table.add_row(*row)

_console.print(table)
rich.print(table)

@contextlib.contextmanager
def logging(self, type_: str = "install") -> Iterator[logging.Logger]:
Expand Down Expand Up @@ -276,12 +278,7 @@ def open_spinner(self, title: str) -> Spinner:

def make_progress(self, *columns: str | ProgressColumn, **kwargs: Any) -> Progress:
"""create a progress instance for indented spinners"""
return Progress(
*columns,
console=_console,
disable=self.verbosity >= Verbosity.DETAIL,
**kwargs,
)
return Progress(*columns, disable=self.verbosity >= Verbosity.DETAIL, **kwargs)

def info(self, message: str, verbosity: Verbosity = Verbosity.QUIET) -> None:
"""Print a message to stdout."""
Expand Down
12 changes: 12 additions & 0 deletions tests/cli/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -900,3 +900,15 @@ def test_empty_positional_args_display_help(project, pdm):
assert "Usage:" in result.output
assert "Commands:" in result.output
assert "Options:" in result.output


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