pymelos 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. pymelos/__init__.py +63 -0
  2. pymelos/__main__.py +6 -0
  3. pymelos/cli/__init__.py +5 -0
  4. pymelos/cli/__main__.py +6 -0
  5. pymelos/cli/app.py +527 -0
  6. pymelos/cli/commands/__init__.py +1 -0
  7. pymelos/cli/commands/init.py +151 -0
  8. pymelos/commands/__init__.py +84 -0
  9. pymelos/commands/add.py +77 -0
  10. pymelos/commands/base.py +108 -0
  11. pymelos/commands/bootstrap.py +154 -0
  12. pymelos/commands/changed.py +161 -0
  13. pymelos/commands/clean.py +142 -0
  14. pymelos/commands/exec.py +116 -0
  15. pymelos/commands/list.py +128 -0
  16. pymelos/commands/release.py +258 -0
  17. pymelos/commands/run.py +160 -0
  18. pymelos/compat.py +14 -0
  19. pymelos/config/__init__.py +47 -0
  20. pymelos/config/loader.py +132 -0
  21. pymelos/config/schema.py +236 -0
  22. pymelos/errors.py +139 -0
  23. pymelos/execution/__init__.py +32 -0
  24. pymelos/execution/parallel.py +249 -0
  25. pymelos/execution/results.py +172 -0
  26. pymelos/execution/runner.py +171 -0
  27. pymelos/filters/__init__.py +27 -0
  28. pymelos/filters/chain.py +101 -0
  29. pymelos/filters/ignore.py +60 -0
  30. pymelos/filters/scope.py +90 -0
  31. pymelos/filters/since.py +98 -0
  32. pymelos/git/__init__.py +69 -0
  33. pymelos/git/changes.py +153 -0
  34. pymelos/git/commits.py +174 -0
  35. pymelos/git/repo.py +210 -0
  36. pymelos/git/tags.py +242 -0
  37. pymelos/py.typed +0 -0
  38. pymelos/types.py +16 -0
  39. pymelos/uv/__init__.py +44 -0
  40. pymelos/uv/client.py +167 -0
  41. pymelos/uv/publish.py +162 -0
  42. pymelos/uv/sync.py +168 -0
  43. pymelos/versioning/__init__.py +57 -0
  44. pymelos/versioning/changelog.py +189 -0
  45. pymelos/versioning/conventional.py +216 -0
  46. pymelos/versioning/semver.py +249 -0
  47. pymelos/versioning/updater.py +146 -0
  48. pymelos/workspace/__init__.py +33 -0
  49. pymelos/workspace/discovery.py +138 -0
  50. pymelos/workspace/graph.py +238 -0
  51. pymelos/workspace/package.py +191 -0
  52. pymelos/workspace/workspace.py +218 -0
  53. pymelos-0.1.3.dist-info/METADATA +106 -0
  54. pymelos-0.1.3.dist-info/RECORD +57 -0
  55. pymelos-0.1.3.dist-info/WHEEL +4 -0
  56. pymelos-0.1.3.dist-info/entry_points.txt +2 -0
  57. pymelos-0.1.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,236 @@
1
+ """Pydantic models for pymelos.yaml configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Annotated, Any
7
+
8
+ from pydantic import BaseModel, Field, field_validator, model_validator
9
+
10
+
11
+ class CommitFormat(str, Enum):
12
+ """Supported commit message formats."""
13
+
14
+ CONVENTIONAL = "conventional"
15
+ ANGULAR = "angular"
16
+
17
+
18
+ class ScriptConfig(BaseModel):
19
+ """Configuration for a script command."""
20
+
21
+ run: str = Field(..., description="Command to execute")
22
+ description: str | None = Field(default=None, description="Human-readable description")
23
+ env: dict[str, str] = Field(default_factory=dict, description="Environment variables")
24
+ scope: str | None = Field(default=None, description="Package scope filter")
25
+ fail_fast: bool = Field(default=False, description="Stop on first failure")
26
+ topological: bool = Field(default=True, description="Respect dependency order")
27
+ pre: list[str] = Field(default_factory=list, description="Commands to run before")
28
+ post: list[str] = Field(default_factory=list, description="Commands to run after")
29
+
30
+
31
+ class BootstrapHook(BaseModel):
32
+ """Configuration for a bootstrap hook."""
33
+
34
+ name: str = Field(..., description="Hook name for display")
35
+ run: str = Field(..., description="Command to execute")
36
+ scope: str | None = Field(default=None, description="Package scope filter")
37
+ run_once: bool = Field(default=False, description="Run only at workspace root")
38
+
39
+
40
+ class BootstrapConfig(BaseModel):
41
+ """Bootstrap command configuration."""
42
+
43
+ hooks: list[BootstrapHook] = Field(default_factory=list, description="Post-sync hooks")
44
+
45
+
46
+ class CleanConfig(BaseModel):
47
+ """Clean command configuration."""
48
+
49
+ patterns: list[str] = Field(
50
+ default_factory=lambda: [
51
+ "__pycache__",
52
+ "*.pyc",
53
+ "*.pyo",
54
+ ".pytest_cache",
55
+ ".mypy_cache",
56
+ ".ruff_cache",
57
+ "*.egg-info",
58
+ "dist",
59
+ "build",
60
+ ".coverage",
61
+ "htmlcov",
62
+ ],
63
+ description="Glob patterns to clean",
64
+ )
65
+ protected: list[str] = Field(
66
+ default_factory=lambda: [".venv", "venv", ".git", "node_modules"],
67
+ description="Patterns to never clean",
68
+ )
69
+
70
+
71
+ class ChangelogSection(BaseModel):
72
+ """Changelog section configuration."""
73
+
74
+ type: str = Field(..., description="Commit type (feat, fix, etc.)")
75
+ title: str = Field(..., description="Section title in changelog")
76
+ hidden: bool = Field(default=False, description="Hide from changelog")
77
+
78
+
79
+ class ChangelogConfig(BaseModel):
80
+ """Changelog generation configuration."""
81
+
82
+ enabled: bool = Field(default=True, description="Generate changelogs")
83
+ filename: str = Field(default="CHANGELOG.md", description="Changelog filename")
84
+ sections: list[ChangelogSection] = Field(
85
+ default_factory=lambda: [
86
+ ChangelogSection(type="feat", title="Features"),
87
+ ChangelogSection(type="fix", title="Bug Fixes"),
88
+ ChangelogSection(type="perf", title="Performance"),
89
+ ChangelogSection(type="refactor", title="Refactoring"),
90
+ ChangelogSection(type="docs", title="Documentation", hidden=True),
91
+ ],
92
+ description="Changelog sections",
93
+ )
94
+
95
+
96
+ class VersioningConfig(BaseModel):
97
+ """Version/release configuration."""
98
+
99
+ commit_format: CommitFormat = Field(
100
+ default=CommitFormat.CONVENTIONAL,
101
+ description="Commit message format",
102
+ )
103
+ changelog: ChangelogConfig = Field(
104
+ default_factory=ChangelogConfig,
105
+ description="Changelog configuration",
106
+ )
107
+ tag_format: str = Field(
108
+ default="{name}@{version}",
109
+ description="Git tag format (use {name} and {version})",
110
+ )
111
+ commit_message: str = Field(
112
+ default="chore(release): {packages}",
113
+ description="Release commit message",
114
+ )
115
+ pre_release_checks: list[str] = Field(
116
+ default_factory=list,
117
+ description="Commands to run before release",
118
+ )
119
+
120
+
121
+ class PublishConfig(BaseModel):
122
+ """Publishing configuration."""
123
+
124
+ registry: str = Field(
125
+ default="https://upload.pypi.org/legacy/",
126
+ description="PyPI registry URL",
127
+ )
128
+ private: list[str] = Field(
129
+ default_factory=list,
130
+ description="Package patterns to never publish",
131
+ )
132
+
133
+
134
+ class CommandDefaults(BaseModel):
135
+ """Default settings for commands."""
136
+
137
+ concurrency: Annotated[int, Field(ge=1, le=32)] = Field(
138
+ default=4,
139
+ description="Default parallel jobs",
140
+ )
141
+ fail_fast: bool = Field(default=False, description="Stop on first failure")
142
+ topological: bool = Field(default=True, description="Respect dependency order")
143
+
144
+
145
+ class VSCodeConfig(BaseModel):
146
+ """VS Code specific settings."""
147
+
148
+ model_config = {"extra": "allow"}
149
+
150
+
151
+ class IDEConfig(BaseModel):
152
+ """IDE integration configuration."""
153
+
154
+ vscode: VSCodeConfig = Field(default_factory=VSCodeConfig)
155
+
156
+
157
+ class PyMelosConfig(BaseModel):
158
+ """Root configuration model for pymelos.yaml."""
159
+
160
+ name: str = Field(..., description="Workspace name")
161
+ packages: list[str] = Field(..., description="Package glob patterns (e.g., ['packages/*'])")
162
+ ignore: list[str] = Field(
163
+ default_factory=list,
164
+ description="Patterns to exclude from package discovery",
165
+ )
166
+ scripts: dict[str, ScriptConfig | str | dict[str, Any]] = Field(
167
+ default_factory=dict,
168
+ description="Scripts that can be run with 'pymelos run <name>'",
169
+ )
170
+ command_defaults: CommandDefaults = Field(
171
+ default_factory=CommandDefaults,
172
+ description="Default command settings",
173
+ )
174
+ bootstrap: BootstrapConfig = Field(
175
+ default_factory=BootstrapConfig,
176
+ description="Bootstrap configuration",
177
+ )
178
+ clean: CleanConfig = Field(
179
+ default_factory=CleanConfig,
180
+ description="Clean configuration",
181
+ )
182
+ versioning: VersioningConfig = Field(
183
+ default_factory=VersioningConfig,
184
+ description="Versioning/release configuration",
185
+ )
186
+ publish: PublishConfig = Field(
187
+ default_factory=PublishConfig,
188
+ description="Publishing configuration",
189
+ )
190
+ ide: IDEConfig = Field(
191
+ default_factory=IDEConfig,
192
+ description="IDE integration settings",
193
+ )
194
+ env: dict[str, str] = Field(
195
+ default_factory=dict,
196
+ description="Environment variables for all commands",
197
+ )
198
+
199
+ @field_validator("scripts", mode="before")
200
+ @classmethod
201
+ def normalize_scripts(cls, v: dict[str, Any]) -> dict[str, ScriptConfig]:
202
+ """Convert simple string scripts to ScriptConfig."""
203
+ if not isinstance(v, dict):
204
+ return v
205
+ result: dict[str, ScriptConfig] = {}
206
+ for name, config in v.items():
207
+ if isinstance(config, str):
208
+ result[name] = ScriptConfig(run=config)
209
+ elif isinstance(config, dict):
210
+ result[name] = ScriptConfig(**config)
211
+ else:
212
+ result[name] = config
213
+ return result
214
+
215
+ @model_validator(mode="after")
216
+ def validate_config(self) -> PyMelosConfig:
217
+ """Validate the complete configuration."""
218
+ if not self.packages:
219
+ raise ValueError("At least one package pattern is required")
220
+ return self
221
+
222
+ def get_script(self, name: str) -> ScriptConfig | None:
223
+ """Get a script configuration by name."""
224
+ script = self.scripts.get(name)
225
+ if script is None:
226
+ return None
227
+ if isinstance(script, str):
228
+ return ScriptConfig(run=script)
229
+ if isinstance(script, dict):
230
+ return ScriptConfig(**script)
231
+ return script
232
+
233
+ @property
234
+ def script_names(self) -> list[str]:
235
+ """Get all defined script names."""
236
+ return list(self.scripts.keys())
pymelos/errors.py ADDED
@@ -0,0 +1,139 @@
1
+ """Exception hierarchy for pymelos.
2
+
3
+ All exceptions inherit from pymelosError to allow catching all pymelos-related
4
+ errors with a single except clause.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+
12
+ class PyMelosError(Exception):
13
+ """Base exception for all pymelos errors."""
14
+
15
+ def __init__(self, message: str) -> None:
16
+ self.message = message
17
+ super().__init__(message)
18
+
19
+
20
+ class ConfigurationError(PyMelosError):
21
+ """Error in pymelos.yaml configuration."""
22
+
23
+ def __init__(self, message: str, path: Path | None = None) -> None:
24
+ self.path = path
25
+ if path:
26
+ message = f"{path}: {message}"
27
+ super().__init__(message)
28
+
29
+
30
+ class WorkspaceNotFoundError(PyMelosError):
31
+ """No pymelos.yaml found in directory tree."""
32
+
33
+ def __init__(self, search_path: Path) -> None:
34
+ self.search_path = search_path
35
+ super().__init__(
36
+ f"No pymelos.yaml found in {search_path} or any parent directory. "
37
+ "Run 'pymelos init' to create a workspace."
38
+ )
39
+
40
+
41
+ class PackageNotFoundError(PyMelosError):
42
+ """Requested package does not exist in workspace."""
43
+
44
+ def __init__(self, name: str, available: list[str] | None = None) -> None:
45
+ self.name = name
46
+ self.available = available or []
47
+ message = f"Package '{name}' not found in workspace."
48
+ if self.available:
49
+ message += f" Available packages: {', '.join(sorted(self.available))}"
50
+ super().__init__(message)
51
+
52
+
53
+ class CyclicDependencyError(PyMelosError):
54
+ """Circular dependency detected in package graph."""
55
+
56
+ def __init__(self, cycle: list[str]) -> None:
57
+ self.cycle = cycle
58
+ cycle_str = " -> ".join(cycle + [cycle[0]])
59
+ super().__init__(f"Cyclic dependency detected: {cycle_str}")
60
+
61
+
62
+ class ScriptNotFoundError(PyMelosError):
63
+ """Requested script is not defined in pymelos.yaml."""
64
+
65
+ def __init__(self, name: str, available: list[str] | None = None) -> None:
66
+ self.name = name
67
+ self.available = available or []
68
+ message = f"Script '{name}' not defined in pymelos.yaml."
69
+ if self.available:
70
+ message += f" Available scripts: {', '.join(sorted(self.available))}"
71
+ super().__init__(message)
72
+
73
+
74
+ class ExecutionError(PyMelosError):
75
+ """Error during command execution."""
76
+
77
+ def __init__(
78
+ self,
79
+ message: str,
80
+ package_name: str | None = None,
81
+ exit_code: int | None = None,
82
+ stderr: str | None = None,
83
+ ) -> None:
84
+ self.package_name = package_name
85
+ self.exit_code = exit_code
86
+ self.stderr = stderr
87
+ if package_name:
88
+ message = f"[{package_name}] {message}"
89
+ if exit_code is not None:
90
+ message += f" (exit code: {exit_code})"
91
+ super().__init__(message)
92
+
93
+
94
+ class BootstrapError(PyMelosError):
95
+ """Error during workspace bootstrap."""
96
+
97
+ pass
98
+
99
+
100
+ class GitError(PyMelosError):
101
+ """Error during git operations."""
102
+
103
+ def __init__(self, message: str, command: str | None = None) -> None:
104
+ self.command = command
105
+ if command:
106
+ message = f"Git command failed: {command}\n{message}"
107
+ super().__init__(message)
108
+
109
+
110
+ class ReleaseError(PyMelosError):
111
+ """Error during release operations."""
112
+
113
+ def __init__(self, message: str, package_name: str | None = None) -> None:
114
+ self.package_name = package_name
115
+ if package_name:
116
+ message = f"[{package_name}] {message}"
117
+ super().__init__(message)
118
+
119
+
120
+ class PublishError(PyMelosError):
121
+ """Error during package publishing."""
122
+
123
+ def __init__(self, message: str, package_name: str, registry: str | None = None) -> None:
124
+ self.package_name = package_name
125
+ self.registry = registry
126
+ full_message = f"Failed to publish {package_name}"
127
+ if registry:
128
+ full_message += f" to {registry}"
129
+ full_message += f": {message}"
130
+ super().__init__(full_message)
131
+
132
+
133
+ class ValidationError(PyMelosError):
134
+ """Validation errors, typically multiple issues."""
135
+
136
+ def __init__(self, errors: list[str]) -> None:
137
+ self.errors = errors
138
+ message = "Validation failed:\n" + "\n".join(f" - {e}" for e in errors)
139
+ super().__init__(message)
@@ -0,0 +1,32 @@
1
+ """Command execution engine."""
2
+
3
+ from pymelos.execution.parallel import (
4
+ ParallelExecutor,
5
+ execute_parallel,
6
+ execute_topological,
7
+ )
8
+ from pymelos.execution.results import (
9
+ BatchResult,
10
+ ExecutionResult,
11
+ ExecutionStatus,
12
+ )
13
+ from pymelos.execution.runner import (
14
+ run_command,
15
+ run_command_sync,
16
+ run_in_package,
17
+ )
18
+
19
+ __all__ = [
20
+ # Results
21
+ "ExecutionResult",
22
+ "ExecutionStatus",
23
+ "BatchResult",
24
+ # Runner
25
+ "run_command",
26
+ "run_command_sync",
27
+ "run_in_package",
28
+ # Parallel
29
+ "ParallelExecutor",
30
+ "execute_parallel",
31
+ "execute_topological",
32
+ ]
@@ -0,0 +1,249 @@
1
+ """Parallel command execution with concurrency control."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import AsyncIterator, Iterator
7
+ from typing import TYPE_CHECKING
8
+
9
+ from pymelos.execution.results import BatchResult, ExecutionResult, ExecutionStatus
10
+ from pymelos.execution.runner import run_in_package
11
+
12
+ if TYPE_CHECKING:
13
+ from pymelos.workspace.package import Package
14
+
15
+
16
+ class ParallelExecutor:
17
+ """Execute commands across packages with controlled parallelism.
18
+
19
+ Supports topological ordering and fail-fast behavior.
20
+
21
+ Attributes:
22
+ concurrency: Maximum number of concurrent executions.
23
+ fail_fast: Stop on first failure.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ concurrency: int = 4,
29
+ fail_fast: bool = False,
30
+ ) -> None:
31
+ """Initialize executor.
32
+
33
+ Args:
34
+ concurrency: Maximum parallel executions.
35
+ fail_fast: Stop on first failure.
36
+ """
37
+ self.concurrency = max(1, concurrency)
38
+ self.fail_fast = fail_fast
39
+ self._cancelled = False
40
+
41
+ async def execute(
42
+ self,
43
+ packages: list[Package],
44
+ command: str,
45
+ *,
46
+ env: dict[str, str] | None = None,
47
+ timeout: float | None = None,
48
+ ) -> BatchResult:
49
+ """Execute command across packages in parallel.
50
+
51
+ Args:
52
+ packages: Packages to run command in.
53
+ command: Shell command to execute.
54
+ env: Environment variables.
55
+ timeout: Per-package timeout in seconds.
56
+
57
+ Returns:
58
+ Batch result with all execution results.
59
+ """
60
+ results: list[ExecutionResult] = []
61
+ self._cancelled = False
62
+
63
+ semaphore = asyncio.Semaphore(self.concurrency)
64
+
65
+ async def run_one(pkg: Package) -> ExecutionResult:
66
+ if self._cancelled:
67
+ return ExecutionResult(
68
+ package_name=pkg.name,
69
+ status=ExecutionStatus.CANCELLED,
70
+ exit_code=-1,
71
+ )
72
+
73
+ async with semaphore:
74
+ if self._cancelled:
75
+ return ExecutionResult(
76
+ package_name=pkg.name,
77
+ status=ExecutionStatus.CANCELLED,
78
+ exit_code=-1,
79
+ )
80
+
81
+ result = await run_in_package(pkg, command, env=env, timeout=timeout)
82
+
83
+ if self.fail_fast and result.failed:
84
+ self._cancelled = True
85
+
86
+ return result
87
+
88
+ tasks = [asyncio.create_task(run_one(pkg)) for pkg in packages]
89
+ results = await asyncio.gather(*tasks)
90
+
91
+ return BatchResult(results=list(results))
92
+
93
+ async def execute_batches(
94
+ self,
95
+ batches: Iterator[list[Package]],
96
+ command: str,
97
+ *,
98
+ env: dict[str, str] | None = None,
99
+ timeout: float | None = None,
100
+ ) -> BatchResult:
101
+ """Execute command across package batches (topological order).
102
+
103
+ Each batch is executed in parallel, but batches are sequential.
104
+
105
+ Args:
106
+ batches: Iterator of package batches (from parallel_batches).
107
+ command: Shell command to execute.
108
+ env: Environment variables.
109
+ timeout: Per-package timeout in seconds.
110
+
111
+ Returns:
112
+ Batch result with all execution results.
113
+ """
114
+ all_results: list[ExecutionResult] = []
115
+ self._cancelled = False
116
+
117
+ for batch in batches:
118
+ if self._cancelled:
119
+ # Mark remaining as cancelled
120
+ for pkg in batch:
121
+ all_results.append(
122
+ ExecutionResult(
123
+ package_name=pkg.name,
124
+ status=ExecutionStatus.CANCELLED,
125
+ exit_code=-1,
126
+ )
127
+ )
128
+ continue
129
+
130
+ batch_result = await self.execute(
131
+ batch,
132
+ command,
133
+ env=env,
134
+ timeout=timeout,
135
+ )
136
+ all_results.extend(batch_result.results)
137
+
138
+ if self.fail_fast and batch_result.any_failure:
139
+ self._cancelled = True
140
+
141
+ return BatchResult(results=all_results)
142
+
143
+ async def stream(
144
+ self,
145
+ packages: list[Package],
146
+ command: str,
147
+ *,
148
+ env: dict[str, str] | None = None,
149
+ timeout: float | None = None,
150
+ ) -> AsyncIterator[ExecutionResult]:
151
+ """Stream execution results as they complete.
152
+
153
+ Args:
154
+ packages: Packages to run command in.
155
+ command: Shell command to execute.
156
+ env: Environment variables.
157
+ timeout: Per-package timeout in seconds.
158
+
159
+ Yields:
160
+ Execution results as they complete.
161
+ """
162
+ self._cancelled = False
163
+ semaphore = asyncio.Semaphore(self.concurrency)
164
+
165
+ async def run_one(pkg: Package) -> ExecutionResult:
166
+ if self._cancelled:
167
+ return ExecutionResult(
168
+ package_name=pkg.name,
169
+ status=ExecutionStatus.CANCELLED,
170
+ exit_code=-1,
171
+ )
172
+
173
+ async with semaphore:
174
+ return await run_in_package(pkg, command, env=env, timeout=timeout)
175
+
176
+ tasks = {asyncio.create_task(run_one(pkg)): pkg for pkg in packages}
177
+ pending = set(tasks.keys())
178
+
179
+ while pending:
180
+ done, pending = await asyncio.wait(
181
+ pending,
182
+ return_when=asyncio.FIRST_COMPLETED,
183
+ )
184
+
185
+ for task in done:
186
+ result = task.result()
187
+ yield result
188
+
189
+ if self.fail_fast and result.failed:
190
+ self._cancelled = True
191
+ # Cancel pending tasks
192
+ for p in pending:
193
+ p.cancel()
194
+
195
+ def cancel(self) -> None:
196
+ """Cancel ongoing executions."""
197
+ self._cancelled = True
198
+
199
+
200
+ async def execute_parallel(
201
+ packages: list[Package],
202
+ command: str,
203
+ *,
204
+ concurrency: int = 4,
205
+ fail_fast: bool = False,
206
+ env: dict[str, str] | None = None,
207
+ timeout: float | None = None,
208
+ ) -> BatchResult:
209
+ """Convenience function for parallel execution.
210
+
211
+ Args:
212
+ packages: Packages to run command in.
213
+ command: Shell command to execute.
214
+ concurrency: Maximum parallel executions.
215
+ fail_fast: Stop on first failure.
216
+ env: Environment variables.
217
+ timeout: Per-package timeout in seconds.
218
+
219
+ Returns:
220
+ Batch result with all execution results.
221
+ """
222
+ executor = ParallelExecutor(concurrency=concurrency, fail_fast=fail_fast)
223
+ return await executor.execute(packages, command, env=env, timeout=timeout)
224
+
225
+
226
+ async def execute_topological(
227
+ batches: Iterator[list[Package]],
228
+ command: str,
229
+ *,
230
+ concurrency: int = 4,
231
+ fail_fast: bool = False,
232
+ env: dict[str, str] | None = None,
233
+ timeout: float | None = None,
234
+ ) -> BatchResult:
235
+ """Execute command in topological order.
236
+
237
+ Args:
238
+ batches: Package batches from workspace.parallel_batches().
239
+ command: Shell command to execute.
240
+ concurrency: Maximum parallel executions per batch.
241
+ fail_fast: Stop on first failure.
242
+ env: Environment variables.
243
+ timeout: Per-package timeout in seconds.
244
+
245
+ Returns:
246
+ Batch result with all execution results.
247
+ """
248
+ executor = ParallelExecutor(concurrency=concurrency, fail_fast=fail_fast)
249
+ return await executor.execute_batches(batches, command, env=env, timeout=timeout)