Skip to content

Commit

Permalink
fix: Add an option for PDM's scripts to set the current working direc…
Browse files Browse the repository at this point in the history
…tory (pdm-project#2694)

Fixes pdm-project#2620

Signed-off-by: Frost Ming <[email protected]>
  • Loading branch information
frostming authored Mar 15, 2024
1 parent 1fac8c9 commit b4da6e0
Show file tree
Hide file tree
Showing 5 changed files with 45 additions and 26 deletions.
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"

0 comments on commit b4da6e0

Please sign in to comment.