Skip to content

Commit 134506c

Browse files
committed
Support parallelizing commands in an alias.
1 parent 88b48dd commit 134506c

File tree

8 files changed

+217
-64
lines changed

8 files changed

+217
-64
lines changed

CHANGES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Release Notes
22

3+
## 0.3.0
4+
5+
Add support for parallelizing execution of commands in an alias.
6+
37
## 0.2.1
48

59
Fix project root dir detection.

dev_cmd/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2024 John Sirois.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "0.2.1"
4+
__version__ = "0.3.0"

dev_cmd/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ class InvalidArgumentError(DevError):
1616

1717
class InvalidModelError(DevError):
1818
"""Indicates invalid dev command configuration."""
19+
20+
21+
class ParallelExecutionError(Exception):
22+
"""Conveys details of 2 or more failed parallel commands."""

dev_cmd/model.py

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,47 @@ class Command:
2222
@dataclass(frozen=True)
2323
class Dev:
2424
commands: Mapping[str, Command]
25-
aliases: Mapping[str, tuple[Command, ...]]
26-
default: tuple[str, tuple[Command, ...]] | None = None
25+
aliases: Mapping[str, tuple[Command | tuple[Command, ...], ...]]
26+
default: tuple[str, tuple[Command | tuple[Command, ...], ...]] | None = None
2727
source: Any = "<code>"
2828

2929

30+
@dataclass
31+
class ExtraArgsChecker:
32+
accepts_extra_args: Command | None = None
33+
34+
def check(self, *commands: Command) -> None:
35+
for command in commands:
36+
if command.accepts_extra_args:
37+
if self.accepts_extra_args is not None:
38+
raise InvalidModelError(
39+
f"The command {command.name!r} accepts extra args, but only one "
40+
f"command can accept extra args per invocation and command "
41+
f"{self.accepts_extra_args.name!r} already does."
42+
)
43+
self.accepts_extra_args = command
44+
45+
3046
@dataclass(frozen=True)
3147
class Invocation:
3248
@classmethod
33-
def create(cls, *tasks: tuple[str, Iterable[Command]]) -> Invocation:
34-
_tasks: dict[str, tuple[Command, ...]] = {}
35-
accepts_extra_args: Command | None = None
49+
def create(cls, *tasks: tuple[str, Iterable[Command | Iterable[Command]]]) -> Invocation:
50+
_tasks: dict[str, tuple[Command | tuple[Command, ...], ...]] = {}
51+
extra_args_checker = ExtraArgsChecker()
3652
for task, commands in tasks:
37-
_tasks[task] = tuple(commands)
53+
task_cmds: list[Command | tuple[Command, ...]] = []
3854
for command in commands:
39-
if command.accepts_extra_args:
40-
if accepts_extra_args is not None:
41-
raise InvalidModelError(
42-
f"The command {command.name!r} accepts extra args, but only one "
43-
f"command can accept extra args per invocation and command "
44-
f"{accepts_extra_args.name!r} already does."
45-
)
46-
accepts_extra_args = command
47-
48-
return cls(tasks=_tasks, accepts_extra_args=accepts_extra_args is not None)
49-
50-
tasks: Mapping[str, tuple[Command, ...]]
55+
if isinstance(command, Command):
56+
extra_args_checker.check(command)
57+
task_cmds.append(command)
58+
else:
59+
extra_args_checker.check(*command)
60+
task_cmds.append(tuple(command))
61+
_tasks[task] = tuple(task_cmds)
62+
63+
return cls(
64+
tasks=_tasks, accepts_extra_args=extra_args_checker.accepts_extra_args is not None
65+
)
66+
67+
tasks: Mapping[str, tuple[Command | tuple[Command, ...], ...]]
5168
accepts_extra_args: bool

dev_cmd/parse.py

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -92,24 +92,51 @@ def _parse_commands(commands: dict[str, Any] | None, project_dir: Path) -> Itera
9292
yield Command(name, env, args, cwd, accepts_extra_args=accepts_extra_args)
9393

9494

95-
def _parse_aliases(aliases: dict[str, Any] | None) -> Iterator[tuple[str, tuple[str, ...]]]:
96-
if aliases:
97-
for alias, commands in aliases.items():
98-
yield alias, tuple(_assert_list_str(commands, path=f"[tool.dev-cmd.aliases.{alias}]"))
95+
def _parse_aliases(
96+
aliases: dict[str, Any] | None,
97+
) -> Iterator[tuple[str, tuple[str | tuple[str, ...], ...]]]:
98+
if not aliases:
99+
return
100+
101+
def iter_commands(alias: str, obj: Any) -> Iterator[str | tuple[str, ...]]:
102+
if not isinstance(commands, list):
103+
raise InvalidModelError(
104+
f"Expected value at [tool.dev-cmd.aliases] `{alias}` to be a list containing "
105+
f"strings or lists of strings, but given: {obj} of type {type(obj)}."
106+
)
107+
108+
for index, item in enumerate(obj):
109+
if isinstance(item, str):
110+
yield item
111+
elif isinstance(item, list):
112+
if not all(isinstance(element, str) for element in item):
113+
raise InvalidModelError(
114+
f"Expected value at [tool.dev-cmd.aliases] `{alias}`[{index}] to be a list "
115+
f"of strings, but given list with at least one non-string item: {item}."
116+
)
117+
yield tuple(item)
118+
else:
119+
raise InvalidModelError(
120+
f"Expected value at [tool.dev-cmd.aliases] `{alias}`[{index}] to be a string "
121+
f"or a list of strings, but given: {item} of type {type(item)}."
122+
)
123+
124+
for alias, commands in aliases.items():
125+
yield alias, tuple(iter_commands(alias, commands))
99126

100127

101128
def _parse_default(
102129
default: dict[str, Any] | None,
103130
commands: Mapping[str, Command],
104-
aliases: Mapping[str, tuple[Command, ...]],
105-
) -> tuple[str, tuple[Command, ...]] | None:
131+
aliases: Mapping[str, tuple[Command | tuple[Command, ...], ...]],
132+
) -> tuple[str, tuple[Command | tuple[Command, ...], ...]] | None:
106133
if not default:
107134
if len(commands) == 1:
108135
name, command = next(iter(commands.items()))
109136
return name, tuple([command])
110137
return None
111138

112-
default_commands: tuple[str, tuple[Command, ...]] | None = None
139+
default_commands: tuple[str, tuple[Command | tuple[Command, ...], ...]] | None = None
113140
alias = default.pop("alias", None)
114141
if alias:
115142
if not isinstance(alias, str):
@@ -162,23 +189,41 @@ def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:
162189
project_dir=pyproject_toml.path.parent,
163190
)
164191
}
165-
aliases: dict[str, tuple[Command, ...]] = {}
166-
for alias, cmds in _parse_aliases(pop_dict("aliases", path="[tool.dev-cmd.aliases-]")):
192+
aliases: dict[str, tuple[Command | tuple[Command, ...], ...]] = {}
193+
for alias, cmds in _parse_aliases(pop_dict("aliases", path="[tool.dev-cmd.aliases]")):
167194
if alias in commands:
168195
raise InvalidModelError(
169196
f"The alias name {alias!r} conflicts with a command of the same name."
170197
)
171-
alias_cmds: list[Command] = []
172-
for cmd in cmds:
173-
if cmd in commands:
174-
alias_cmds.append(commands[cmd])
175-
elif cmd in aliases:
176-
alias_cmds.extend(aliases[cmd])
198+
alias_cmds: list[Command | tuple[Command, ...]] = []
199+
for index, cmd in enumerate(cmds):
200+
if isinstance(cmd, str):
201+
if cmd in commands:
202+
alias_cmds.append(commands[cmd])
203+
elif cmd in aliases:
204+
alias_cmds.extend(aliases[cmd])
205+
else:
206+
raise InvalidModelError(
207+
f"The task {cmd!r} defined in alias {alias!r} is neither a command nor a "
208+
f"previously defined alias."
209+
)
177210
else:
178-
raise InvalidModelError(
179-
f"The task {cmd!r} defined in alias {alias!r} is neither a command nor a "
180-
f"previously defined alias."
181-
)
211+
parallel_cmds: list[Command] = []
212+
for parallel_cmd in cmd:
213+
if parallel_cmd in commands:
214+
parallel_cmds.append(commands[parallel_cmd])
215+
elif parallel_cmd in aliases:
216+
raise InvalidModelError(
217+
f"Expected value at [tool.dev-cmd.aliases] `{alias}`[{index}] to be a "
218+
f"list of command names, but {parallel_cmd!r} is an alias."
219+
)
220+
else:
221+
raise InvalidModelError(
222+
f"Expected value at [tool.dev-cmd.aliases] `{alias}`[{index}] to be a "
223+
f"list of command names, but {parallel_cmd!r} is doesn't correspond "
224+
f"with any defined command."
225+
)
226+
alias_cmds.append(tuple(parallel_cmds))
182227
aliases[alias] = tuple(alias_cmds)
183228

184229
default = _parse_default(pop_dict("default", path="[tool.dev-cmd.default]"), commands, aliases)

dev_cmd/run.py

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,91 @@
33

44
from __future__ import annotations
55

6+
import asyncio
67
import os
7-
import subprocess
88
import sys
99
from argparse import ArgumentParser
10+
from asyncio.subprocess import Process
1011
from subprocess import CalledProcessError
1112
from typing import Any, Iterable
1213

14+
import aioconsole
1315
from colors import colors
1416

1517
from dev_cmd import __version__
16-
from dev_cmd.errors import DevError, InvalidArgumentError, InvalidModelError
17-
from dev_cmd.model import Dev, Invocation
18+
from dev_cmd.errors import DevError, InvalidArgumentError, InvalidModelError, ParallelExecutionError
19+
from dev_cmd.model import Command, Dev, Invocation
1820
from dev_cmd.parse import parse_dev_config
1921
from dev_cmd.project import find_pyproject_toml
2022

2123

24+
async def _invoke_command(command, extra_args, **subprocess_kwargs: Any) -> Process:
25+
args = list(command.args)
26+
if extra_args and command.accepts_extra_args:
27+
args.extend(extra_args)
28+
if not os.path.exists(command.cwd):
29+
raise InvalidModelError(
30+
f"The `cwd` for command {command.name!r} does not exist: {command.cwd}"
31+
)
32+
return await asyncio.create_subprocess_exec(
33+
command.args[0], *command.args[1:], **subprocess_kwargs
34+
)
35+
36+
37+
async def _invoke(invocation: Invocation, extra_args: Iterable[str] = ()) -> None:
38+
for task, commands in invocation.tasks.items():
39+
prefix = colors.cyan(f"dev-cmd {colors.bold(task)}]")
40+
for command in commands:
41+
if isinstance(command, Command):
42+
await aioconsole.aprint(
43+
f"{prefix} {colors.magenta(f'Executing {colors.bold(command.name)}...')}",
44+
use_stderr=True,
45+
)
46+
process = await _invoke_command(command, extra_args)
47+
returncode = await process.wait()
48+
if returncode != 0:
49+
raise CalledProcessError(returncode=returncode, cmd=command.args)
50+
else:
51+
message = colors.magenta(
52+
f"Parallelizing {len(command)} commands: "
53+
f"{colors.bold(' '.join(cmd.name for cmd in command))}"
54+
)
55+
await aioconsole.aprint(f"{prefix} {message}...", use_stderr=True)
56+
processes = [
57+
await _invoke_command(
58+
cmd,
59+
extra_args,
60+
stdout=asyncio.subprocess.PIPE,
61+
stderr=asyncio.subprocess.STDOUT,
62+
)
63+
for cmd in command
64+
]
65+
66+
errors: list[tuple[str, CalledProcessError]] = []
67+
for cmd, process, (stdout, _) in zip(
68+
command,
69+
processes,
70+
await asyncio.gather(*[process.communicate() for process in processes]),
71+
):
72+
assert process.returncode is not None
73+
if process.returncode != 0:
74+
errors.append(
75+
(
76+
cmd.name,
77+
CalledProcessError(returncode=process.returncode, cmd=cmd.args),
78+
)
79+
)
80+
cmd_name = colors.color(
81+
cmd.name, fg="magenta" if process.returncode == 0 else "red", style="bold"
82+
)
83+
await aioconsole.aprint(f"{prefix} {cmd_name}:", use_stderr=True)
84+
await aioconsole.aprint(stdout.decode(), end="", use_stderr=True)
85+
if errors:
86+
lines = [f"{len(errors)} of {len(command)} parallel commands in {task} failed:"]
87+
lines.extend(f"{cmd}: {error}" for cmd, error in errors)
88+
raise ParallelExecutionError(os.linesep.join(lines))
89+
90+
2291
def _run(dev: Dev, *tasks: str, extra_args: Iterable[str] = ()) -> None:
2392
if tasks:
2493
try:
@@ -57,22 +126,7 @@ def _run(dev: Dev, *tasks: str, extra_args: Iterable[str] = ()) -> None:
57126
f"arguments: {extra_args}"
58127
)
59128

60-
for task, commands in invocation.tasks.items():
61-
prefix = colors.cyan(f"dev-cmd {colors.bold(task)}]")
62-
for command in commands:
63-
print(
64-
f"{prefix} {colors.magenta(f'Executing {colors.bold(command.name)}...')}",
65-
file=sys.stderr,
66-
)
67-
args = list(command.args)
68-
if extra_args and command.accepts_extra_args:
69-
args.extend(extra_args)
70-
71-
if not os.path.exists(command.cwd):
72-
raise InvalidModelError(
73-
f"The `cwd` for command {command.name!r} does not exist: {command.cwd}"
74-
)
75-
subprocess.run(args, env=command.env, cwd=command.cwd, check=True)
129+
return asyncio.run(_invoke(invocation, extra_args))
76130

77131

78132
def _parse_args() -> tuple[list[str], list[str]]:
@@ -115,7 +169,7 @@ def main() -> Any:
115169
return _run(dev, *tasks, extra_args=extra_args)
116170
except DevError as e:
117171
return f"{colors.red('Configuration error')}: {colors.yellow(str(e))}"
118-
except (OSError, CalledProcessError) as e:
172+
except (OSError, CalledProcessError, ParallelExecutionError) as e:
119173
return colors.red(str(e))
120174

121175

pyproject.toml

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ backend = "setuptools.build_meta"
66
name = "dev-cmd"
77
requires-python = ">=3.9"
88
dependencies = [
9+
"aioconsole",
910
"ansicolors",
1011
"tomlkit; python_version < '3.11'",
1112
]
@@ -63,6 +64,10 @@ dev = [
6364
"types-tqdm",
6465
]
6566

67+
[[tool.mypy.overrides]]
68+
module = ["aioconsole.*"]
69+
follow_untyped_imports = true
70+
6671
[[tool.mypy.overrides]]
6772
module = ["colors.*"]
6873
follow_untyped_imports = true
@@ -100,17 +105,30 @@ check-fmt = ["ruff", "format", "--diff"]
100105
lint = ["ruff", "check", "--fix"]
101106
check-lint = ["ruff", "check"]
102107

103-
type-check = ["mypy", "dev_cmd"]
104-
type-check-39 = ["mypy", "--python-version", "3.9", "dev_cmd"]
105-
type-check-313 = ["mypy", "--python-version", "3.13", "dev_cmd"]
108+
type-check = ["mypy", "dev_cmd", "tests"]
109+
type-check-39 = ["mypy", "--python-version", "3.9", "dev_cmd", "tests"]
110+
type-check-310 = ["mypy", "--python-version", "3.10", "dev_cmd", "tests"]
111+
type-check-311 = ["mypy", "--python-version", "3.11", "dev_cmd", "tests"]
112+
type-check-312 = ["mypy", "--python-version", "3.12", "dev_cmd", "tests"]
113+
type-check-313 = ["mypy", "--python-version", "3.13", "dev_cmd", "tests"]
106114

107115
[tool.dev-cmd.commands.test]
108116
args = ["pytest", "-n", "auto"]
109117
accepts-extra-args = true
110118

111119
[tool.dev-cmd.aliases]
112-
checks = ["fmt", "lint", "type-check-39", "type-check-313", "test"]
113-
ci = ["check-fmt", "check-lint", "type-check", "test"]
120+
checks = [
121+
"fmt",
122+
"lint",
123+
# Parallelizing the type checks is safe (they don't modify files), and it nets a ~30% speedup
124+
# over running them all serially.
125+
["type-check-39", "type-check-310", "type-check-311", "type-check-312", "type-check-313"],
126+
"test"
127+
]
128+
ci = [
129+
["check-fmt", "check-lint", "type-check"],
130+
"test"
131+
]
114132

115133
[tool.dev-cmd.default]
116134
alias = "checks"

0 commit comments

Comments
 (0)