Skip to content

Commit 72f87db

Browse files
authored
Refactor tool execution logic (#189)
1 parent ec05716 commit 72f87db

File tree

10 files changed

+160
-137
lines changed

10 files changed

+160
-137
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
## Unreleased
1010

11+
***Changed:***
12+
13+
- Rename the `[git.user]` section to `[tools.git.author]`
14+
- The `Tool` interface now uses a single execution context instead of the dedicated methods `format_command` and `env_vars`
15+
16+
***Added:***
17+
18+
- Add `git` tool
19+
- Add `[user]` section to the configuration
20+
- Add abstract context manager `execution_context` method to the `Tool` interface
21+
1122
## 0.26.0 - 2025-09-09
1223

1324
***Added:***

docs/reference/interface/tool.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
-----
44

5+
::: dda.tools.base.ExecutionContext
6+
57
::: dda.tools.base.Tool
68
options:
79
show_labels: true

src/dda/config/model/user.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ class UserConfig(Struct, frozen=True):
1414
name = "U.N. Owen"
1515
email = "void@some.where"
1616
```
17-
These values will be used for dda-related functionality, such as telemetry.
18-
Both `email` and `name` can be set to `auto`, in which case they will be equal to the values in the [`[tools.git]`][dda.config.model.tools.GitConfig] section.
17+
18+
These values will be used for `dda`-related functionality like telemetry. Both `email` and `name` can be
19+
set to `auto`, in which case they will be equal to the values in the
20+
[`[tools.git.author]`][dda.config.model.tools.GitAuthorConfig] section.
1921
///
2022
"""
2123

22-
# Default username and email are equal to the values in [tools.git]
2324
name: str = "auto"
2425
email: str = "auto"

src/dda/tools/base.py

Lines changed: 68 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,36 @@
44
from __future__ import annotations
55

66
from abc import ABC, abstractmethod
7+
from contextlib import contextmanager
78
from typing import TYPE_CHECKING, Any, NoReturn
89

10+
from msgspec import Struct
11+
912
from dda.utils.process import EnvVars
1013

1114
if TYPE_CHECKING:
15+
from collections.abc import Generator
1216
from subprocess import CompletedProcess
13-
from types import TracebackType
1417

1518
from dda.cli.application import Application
1619

1720

18-
class Tool(ABC):
21+
class ExecutionContext(Struct, frozen=True):
1922
"""
20-
Base class for all tools. A tool is an executable that may require special
21-
handling to be executed properly.
22-
23-
This class supports being used as a context manager and is guaranteed to be entered at all times.
23+
Configuration for an execution of a tool.
2424
"""
2525

26-
def __init__(self, app: Application) -> None:
27-
self.__app = app
26+
command: list[str]
27+
env_vars: dict[str, str]
2828

29-
@abstractmethod
30-
def format_command(self, command: list[str]) -> list[str]:
31-
"""
32-
Format a command to be executed by the tool.
3329

34-
Parameters:
35-
command: The command to format.
30+
class Tool(ABC):
31+
"""
32+
A tool is an external program that may require special handling to be executed properly.
33+
"""
3634

37-
Returns:
38-
The formatted command.
39-
"""
35+
def __init__(self, app: Application) -> None:
36+
self.__app = app
4037

4138
@property
4239
def app(self) -> Application:
@@ -45,18 +42,24 @@ def app(self) -> Application:
4542
"""
4643
return self.__app
4744

48-
def env_vars(self) -> dict[str, str]: # noqa: PLR6301
45+
@contextmanager
46+
@abstractmethod
47+
def execution_context(self, command: list[str]) -> Generator[ExecutionContext, None, None]:
4948
"""
50-
Returns:
51-
The environment variables to set for the tool.
49+
A context manager bound to the lifecycle of each tool execution.
50+
51+
Parameters:
52+
command: The command to execute.
53+
54+
Yields:
55+
The execution context.
5256
"""
53-
return {}
5457

5558
def run(self, command: list[str], **kwargs: Any) -> int:
5659
"""
57-
Equivalent to [`SubprocessRunner.run`][dda.utils.process.SubprocessRunner.run] with the `command` formatted
58-
by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment variables set
59-
by the tool's [`env_vars`][dda.tools.base.Tool.env_vars] method (if any).
60+
Equivalent to [`SubprocessRunner.run`][dda.utils.process.SubprocessRunner.run] with the tool's
61+
[`execution_context`][dda.tools.base.Tool.execution_context] determining the final command and
62+
environment variables.
6063
6164
Parameters:
6265
command: The command to execute.
@@ -65,15 +68,15 @@ def run(self, command: list[str], **kwargs: Any) -> int:
6568
**kwargs: Additional keyword arguments to pass to
6669
[`SubprocessRunner.run`][dda.utils.process.SubprocessRunner.run].
6770
"""
68-
with self:
69-
self.__populate_env_vars(kwargs)
70-
return self.app.subprocess.run(self.format_command(command), **kwargs)
71+
with self.execution_context(command) as context:
72+
_populate_env_vars(kwargs, context.env_vars)
73+
return self.app.subprocess.run(context.command, **kwargs)
7174

7275
def capture(self, command: list[str], **kwargs: Any) -> str:
7376
"""
74-
Equivalent to [`SubprocessRunner.capture`][dda.utils.process.SubprocessRunner.capture] with the `command`
75-
formatted by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment
76-
variables set by the tool's [`env_vars`][dda.tools.base.Tool.env_vars] method (if any).
77+
Equivalent to [`SubprocessRunner.capture`][dda.utils.process.SubprocessRunner.capture] with the tool's
78+
[`execution_context`][dda.tools.base.Tool.execution_context] determining the final command and
79+
environment variables.
7780
7881
Parameters:
7982
command: The command to execute.
@@ -82,15 +85,15 @@ def capture(self, command: list[str], **kwargs: Any) -> str:
8285
**kwargs: Additional keyword arguments to pass to
8386
[`SubprocessRunner.capture`][dda.utils.process.SubprocessRunner.capture].
8487
"""
85-
with self:
86-
self.__populate_env_vars(kwargs)
87-
return self.app.subprocess.capture(self.format_command(command), **kwargs)
88+
with self.execution_context(command) as context:
89+
_populate_env_vars(kwargs, context.env_vars)
90+
return self.app.subprocess.capture(context.command, **kwargs)
8891

8992
def wait(self, command: list[str], **kwargs: Any) -> None:
9093
"""
91-
Equivalent to [`SubprocessRunner.wait`][dda.utils.process.SubprocessRunner.wait] with the `command` formatted
92-
by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment variables set
93-
by the tool's [`env_vars`][dda.tools.base.Tool.env_vars] method (if any).
94+
Equivalent to [`SubprocessRunner.wait`][dda.utils.process.SubprocessRunner.wait] with the tool's
95+
[`execution_context`][dda.tools.base.Tool.execution_context] determining the final command and
96+
environment variables.
9497
9598
Parameters:
9699
command: The command to execute.
@@ -99,15 +102,15 @@ def wait(self, command: list[str], **kwargs: Any) -> None:
99102
**kwargs: Additional keyword arguments to pass to
100103
[`SubprocessRunner.wait`][dda.utils.process.SubprocessRunner.wait].
101104
"""
102-
with self:
103-
self.__populate_env_vars(kwargs)
104-
self.app.subprocess.wait(self.format_command(command), **kwargs)
105+
with self.execution_context(command) as context:
106+
_populate_env_vars(kwargs, context.env_vars)
107+
self.app.subprocess.wait(context.command, **kwargs)
105108

106109
def exit_with(self, command: list[str], **kwargs: Any) -> NoReturn:
107110
"""
108-
Equivalent to [`SubprocessRunner.exit_with`][dda.utils.process.SubprocessRunner.exit_with]
109-
with the `command` formatted by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and
110-
the environment variables set by the tool's [`env_vars`][dda.tools.base.Tool.env_vars] method (if any).
111+
Equivalent to [`SubprocessRunner.exit_with`][dda.utils.process.SubprocessRunner.exit_with] with the tool's
112+
[`execution_context`][dda.tools.base.Tool.execution_context] determining the final command and
113+
environment variables.
111114
112115
Parameters:
113116
command: The command to execute.
@@ -116,15 +119,15 @@ def exit_with(self, command: list[str], **kwargs: Any) -> NoReturn:
116119
**kwargs: Additional keyword arguments to pass to
117120
[`SubprocessRunner.exit_with`][dda.utils.process.SubprocessRunner.exit_with].
118121
"""
119-
with self:
120-
self.__populate_env_vars(kwargs)
121-
self.app.subprocess.exit_with(self.format_command(command), **kwargs)
122+
with self.execution_context(command) as context:
123+
_populate_env_vars(kwargs, context.env_vars)
124+
self.app.subprocess.exit_with(context.command, **kwargs)
122125

123126
def attach(self, command: list[str], **kwargs: Any) -> CompletedProcess:
124127
"""
125-
Equivalent to [`SubprocessRunner.attach`][dda.utils.process.SubprocessRunner.attach] with the `command`
126-
formatted by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment
127-
variables set by the tool's [`env_vars`][dda.tools.base.Tool.env_vars] method (if any).
128+
Equivalent to [`SubprocessRunner.attach`][dda.utils.process.SubprocessRunner.attach] with the tool's
129+
[`execution_context`][dda.tools.base.Tool.execution_context] determining the final command and
130+
environment variables.
128131
129132
Parameters:
130133
command: The command to execute.
@@ -133,15 +136,15 @@ def attach(self, command: list[str], **kwargs: Any) -> CompletedProcess:
133136
**kwargs: Additional keyword arguments to pass to
134137
[`SubprocessRunner.attach`][dda.utils.process.SubprocessRunner.attach].
135138
"""
136-
with self:
137-
self.__populate_env_vars(kwargs)
138-
return self.app.subprocess.attach(self.format_command(command), **kwargs)
139+
with self.execution_context(command) as context:
140+
_populate_env_vars(kwargs, context.env_vars)
141+
return self.app.subprocess.attach(context.command, **kwargs)
139142

140143
def redirect(self, command: list[str], **kwargs: Any) -> CompletedProcess:
141144
"""
142-
Equivalent to [`SubprocessRunner.redirect`][dda.utils.process.SubprocessRunner.redirect] with the `command`
143-
formatted by the tool's [`format_command`][dda.tools.base.Tool.format_command] method and the environment
144-
variables set by the tool's [`env_vars`][dda.tools.base.Tool.env_vars] method (if any).
145+
Equivalent to [`SubprocessRunner.redirect`][dda.utils.process.SubprocessRunner.redirect] with the tool's
146+
[`execution_context`][dda.tools.base.Tool.execution_context] determining the final command and
147+
environment variables.
145148
146149
Parameters:
147150
command: The command to execute.
@@ -150,23 +153,17 @@ def redirect(self, command: list[str], **kwargs: Any) -> CompletedProcess:
150153
**kwargs: Additional keyword arguments to pass to
151154
[`SubprocessRunner.redirect`][dda.utils.process.SubprocessRunner.redirect].
152155
"""
153-
with self:
154-
self.__populate_env_vars(kwargs)
155-
return self.app.subprocess.redirect(self.format_command(command), **kwargs)
156-
157-
def __populate_env_vars(self, kwargs: dict[str, Any]) -> None:
158-
env_vars = self.env_vars()
159-
if not env_vars:
160-
return
156+
with self.execution_context(command) as context:
157+
_populate_env_vars(kwargs, context.env_vars)
158+
return self.app.subprocess.redirect(context.command, **kwargs)
161159

162-
if isinstance(env := kwargs.get("env"), dict):
163-
for key, value in env_vars.items():
164-
env.setdefault(key, value)
165-
else:
166-
kwargs["env"] = EnvVars(env_vars)
167160

168-
def __enter__(self) -> None: ...
161+
def _populate_env_vars(kwargs: dict[str, Any], env_vars: dict[str, str]) -> None:
162+
if not env_vars:
163+
return
169164

170-
def __exit__(
171-
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
172-
) -> None: ...
165+
if isinstance(env := kwargs.get("env"), dict):
166+
for key, value in env_vars.items():
167+
env.setdefault(key, value)
168+
else:
169+
kwargs["env"] = EnvVars(env_vars)

src/dda/tools/bazel.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
# SPDX-License-Identifier: MIT
44
from __future__ import annotations
55

6+
from contextlib import contextmanager
67
from functools import cached_property
78
from typing import TYPE_CHECKING
89

9-
from dda.tools.base import Tool
10+
from dda.tools.base import ExecutionContext, Tool
1011
from dda.utils.platform import PLATFORM_ID, which
1112

1213
if TYPE_CHECKING:
14+
from collections.abc import Generator
15+
1316
from dda.utils.fs import Path
1417

1518

@@ -25,8 +28,9 @@ class Bazel(Tool):
2528
to an internal location if `bazel` nor `bazelisk` are already on PATH.
2629
"""
2730

28-
def format_command(self, command: list[str]) -> list[str]:
29-
return [self.path, *command]
31+
@contextmanager
32+
def execution_context(self, command: list[str]) -> Generator[ExecutionContext, None, None]:
33+
yield ExecutionContext(command=[self.path, *command], env_vars={})
3034

3135
@property
3236
def managed(self) -> bool:

src/dda/tools/docker.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@
33
# SPDX-License-Identifier: MIT
44
from __future__ import annotations
55

6+
from contextlib import contextmanager
67
from functools import cached_property
8+
from typing import TYPE_CHECKING
79

8-
from dda.tools.base import Tool
10+
from dda.tools.base import ExecutionContext, Tool
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Generator
914

1015

1116
class Docker(Tool):
@@ -20,11 +25,9 @@ class Docker(Tool):
2025
```
2126
"""
2227

23-
def format_command(self, command: list[str]) -> list[str]:
24-
return [self.path, *command]
25-
26-
def env_vars(self) -> dict[str, str]: # noqa: PLR6301
27-
return {"DOCKER_CLI_HINTS": "0"}
28+
@contextmanager
29+
def execution_context(self, command: list[str]) -> Generator[ExecutionContext, None, None]:
30+
yield ExecutionContext(command=[self.path, *command], env_vars={"DOCKER_CLI_HINTS": "0"})
2831

2932
@cached_property
3033
def path(self) -> str:

src/dda/tools/git.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
# SPDX-License-Identifier: MIT
44
from __future__ import annotations
55

6+
from contextlib import contextmanager
67
from functools import cached_property
8+
from typing import TYPE_CHECKING
79

8-
from dda.tools.base import Tool
10+
from dda.tools.base import ExecutionContext, Tool
911
from dda.utils.git.constants import GitEnvVars
1012

13+
if TYPE_CHECKING:
14+
from collections.abc import Generator
15+
1116

1217
class Git(Tool):
1318
"""
@@ -18,27 +23,26 @@ class Git(Tool):
1823
```
1924
"""
2025

21-
def env_vars(self) -> dict[str, str]:
22-
name = self.app.config.tools.git.author.name.strip()
23-
email = self.app.config.tools.git.author.email.strip()
24-
result = {}
25-
if name:
26-
result[GitEnvVars.AUTHOR_NAME] = name
27-
result[GitEnvVars.COMMITTER_NAME] = name
28-
if email:
29-
result[GitEnvVars.AUTHOR_EMAIL] = email
30-
result[GitEnvVars.COMMITTER_EMAIL] = email
31-
return result
26+
@contextmanager
27+
def execution_context(self, command: list[str]) -> Generator[ExecutionContext, None, None]:
28+
author_name = self.app.config.tools.git.author.name.strip()
29+
author_email = self.app.config.tools.git.author.email.strip()
30+
env_vars = {}
31+
if author_name:
32+
env_vars[GitEnvVars.AUTHOR_NAME] = author_name
33+
env_vars[GitEnvVars.COMMITTER_NAME] = author_name
34+
if author_email:
35+
env_vars[GitEnvVars.AUTHOR_EMAIL] = author_email
36+
env_vars[GitEnvVars.COMMITTER_EMAIL] = author_email
37+
38+
yield ExecutionContext(command=[self.path, *command], env_vars=env_vars)
3239

3340
@cached_property
3441
def path(self) -> str:
3542
import shutil
3643

3744
return shutil.which("git") or "git"
3845

39-
def format_command(self, command: list[str]) -> list[str]:
40-
return [self.path, *command]
41-
4246
@cached_property
4347
def author_name(self) -> str:
4448
"""

0 commit comments

Comments
 (0)