Repository: pdm-project/pdm

Issue:
Add an option for PDM's scripts to set the current working directory
## Is your feature/enhancement proposal related to a problem? Please describe.

This issue suggest adding a new key for commands defined under `tool.pdm.scripts`, to set the current working directory before running the given action.

Consider the following project structure:

```
my_project/
├─ docs/
│  ├─ source/
│  ├─ Makefile
│  ├─ make.bat
├─ src/
│  ├─ my_package/
├─ pyproject.toml
```
This is a recurring structure given Sphinx's proposed setup.
There are commands that can only be ran in the `docs/` directory, due to the nature of `make` that uses the `Makefile` available at the root of the current working directory.

## Describe the solution you'd like

This key, which could be called `cwd`, or `in_path`, or any given suggestion, would provide an OS-agnostic way to set the current working directory.

When a command will be running, and this property is set with a relative path (In this example `./docs`), PDM should come back to the root of the project (`pdm info --where`), so that moving in directories is seamless, and would not surprise us in case our current working directory was in `src/my_package`, for example.

Alternatively, this could be done by using operators like `&&` in a shell script, but such operator (In my knowledge) could be different depending on the OS used.

Adding such key would, in my opinion, increase confidence when creating new scripts that require to move to specific directories.

- - -

I am willing to work on this issue and submit a PR.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/2694?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 84.81%. Comparing base [(`d4a13c5`)](https://app.codecov.io/gh/pdm-project/pdm/commit/d4a13c50fc90394bb0a33f616c1bd4de11c33362?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`9d09806`)](https://app.codecov.io/gh/pdm-project/pdm/pull/2694?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).
> Report is 1 commits behind head on main.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #2694   +/-   ##
=======================================
  Coverage   84.80%   84.81%           
=======================================
  Files         104      104           
  Lines       10309    10311    +2     
  Branches     2237     2237           
=======================================
+ Hits         8743     8745    +2     
  Misses       1096     1096           
  Partials      470      470           
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/2694/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/2694/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `84.59% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/2694?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/docs/docs/usage/scripts.md b/docs/docs/usage/scripts.md
index 483d7c1b..12c97c66 100644
--- a/docs/docs/usage/scripts.md
+++ b/docs/docs/usage/scripts.md
@@ -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`:
 
@@ -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,
diff --git a/news/2620.feature.md b/news/2620.feature.md
new file mode 100644
index 00000000..e3ad4ffb
--- /dev/null
+++ b/news/2620.feature.md
@@ -0,0 +1,1 @@
+Add an option `working_dir` for PDM's scripts to set the current working directory.
diff --git a/src/pdm/cli/commands/run.py b/src/pdm/cli/commands/run.py
index 36f7ed64..506072eb 100644
--- a/src/pdm/cli/commands/run.py
+++ b/src/pdm/cli/commands/run.py
@@ -36,11 +36,12 @@ if TYPE_CHECKING:
         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")},
     )
 
@@ -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
@@ -159,6 +160,7 @@ class TaskRunner:
         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
@@ -213,7 +215,7 @@ class TaskRunner:
                 # 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:
@@ -285,12 +287,7 @@ class TaskRunner:
                         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:
@@ -312,11 +309,7 @@ class TaskRunner:
             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:
diff --git a/src/pdm/termui.py b/src/pdm/termui.py
index 22eee13c..2984571e 100644
--- a/src/pdm/termui.py
+++ b/src/pdm/termui.py
@@ -8,6 +8,7 @@ import tempfile
 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
@@ -36,21 +37,21 @@ DEFAULT_THEME = {
     "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
 
 
@@ -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:
@@ -176,7 +178,7 @@ class UI:
 
         :param theme: dict of theme
         """
-        _console.push_theme(theme)
+        rich.get_console().push_theme(theme)
         _err_console.push_theme(theme)
 
     def echo(
@@ -193,7 +195,7 @@ class UI:
         :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")
@@ -223,7 +225,7 @@ class UI:
         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]:
@@ -276,12 +278,7 @@ class UI:
 
     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."""


Test patch:
diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py
index e2d4c4d9..6e1e4c03 100644
--- a/tests/cli/test_run.py
+++ b/tests/cli/test_run.py
@@ -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"