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.
- pymelos/__init__.py +63 -0
- pymelos/__main__.py +6 -0
- pymelos/cli/__init__.py +5 -0
- pymelos/cli/__main__.py +6 -0
- pymelos/cli/app.py +527 -0
- pymelos/cli/commands/__init__.py +1 -0
- pymelos/cli/commands/init.py +151 -0
- pymelos/commands/__init__.py +84 -0
- pymelos/commands/add.py +77 -0
- pymelos/commands/base.py +108 -0
- pymelos/commands/bootstrap.py +154 -0
- pymelos/commands/changed.py +161 -0
- pymelos/commands/clean.py +142 -0
- pymelos/commands/exec.py +116 -0
- pymelos/commands/list.py +128 -0
- pymelos/commands/release.py +258 -0
- pymelos/commands/run.py +160 -0
- pymelos/compat.py +14 -0
- pymelos/config/__init__.py +47 -0
- pymelos/config/loader.py +132 -0
- pymelos/config/schema.py +236 -0
- pymelos/errors.py +139 -0
- pymelos/execution/__init__.py +32 -0
- pymelos/execution/parallel.py +249 -0
- pymelos/execution/results.py +172 -0
- pymelos/execution/runner.py +171 -0
- pymelos/filters/__init__.py +27 -0
- pymelos/filters/chain.py +101 -0
- pymelos/filters/ignore.py +60 -0
- pymelos/filters/scope.py +90 -0
- pymelos/filters/since.py +98 -0
- pymelos/git/__init__.py +69 -0
- pymelos/git/changes.py +153 -0
- pymelos/git/commits.py +174 -0
- pymelos/git/repo.py +210 -0
- pymelos/git/tags.py +242 -0
- pymelos/py.typed +0 -0
- pymelos/types.py +16 -0
- pymelos/uv/__init__.py +44 -0
- pymelos/uv/client.py +167 -0
- pymelos/uv/publish.py +162 -0
- pymelos/uv/sync.py +168 -0
- pymelos/versioning/__init__.py +57 -0
- pymelos/versioning/changelog.py +189 -0
- pymelos/versioning/conventional.py +216 -0
- pymelos/versioning/semver.py +249 -0
- pymelos/versioning/updater.py +146 -0
- pymelos/workspace/__init__.py +33 -0
- pymelos/workspace/discovery.py +138 -0
- pymelos/workspace/graph.py +238 -0
- pymelos/workspace/package.py +191 -0
- pymelos/workspace/workspace.py +218 -0
- pymelos-0.1.3.dist-info/METADATA +106 -0
- pymelos-0.1.3.dist-info/RECORD +57 -0
- pymelos-0.1.3.dist-info/WHEEL +4 -0
- pymelos-0.1.3.dist-info/entry_points.txt +2 -0
- pymelos-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Dependency graph building and topological sorting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from graphlib import CycleError, TopologicalSorter
|
|
8
|
+
|
|
9
|
+
from pymelos.errors import CyclicDependencyError
|
|
10
|
+
from pymelos.workspace.package import Package
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class DependencyGraph:
|
|
15
|
+
"""Package dependency graph with topological ordering support.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
packages: Dictionary of package name to Package.
|
|
19
|
+
_sorter: Cached TopologicalSorter instance.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
packages: dict[str, Package]
|
|
23
|
+
_edges: dict[str, set[str]] = field(default_factory=dict, init=False)
|
|
24
|
+
_reverse_edges: dict[str, set[str]] = field(default_factory=dict, init=False)
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
"""Build the dependency edges."""
|
|
28
|
+
# Initialize edge sets
|
|
29
|
+
for name in self.packages:
|
|
30
|
+
self._edges[name] = set()
|
|
31
|
+
self._reverse_edges[name] = set()
|
|
32
|
+
|
|
33
|
+
# Build edges: package -> its workspace dependencies
|
|
34
|
+
for name, package in self.packages.items():
|
|
35
|
+
for dep in package.workspace_dependencies:
|
|
36
|
+
# Normalize and check if dependency exists in workspace
|
|
37
|
+
normalized_dep = dep.lower().replace("-", "_")
|
|
38
|
+
for pkg_name in self.packages:
|
|
39
|
+
if pkg_name.lower().replace("-", "_") == normalized_dep:
|
|
40
|
+
self._edges[name].add(pkg_name)
|
|
41
|
+
self._reverse_edges[pkg_name].add(name)
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def roots(self) -> list[Package]:
|
|
46
|
+
"""Get packages with no dependencies (leaf nodes)."""
|
|
47
|
+
return [self.packages[name] for name, deps in self._edges.items() if not deps]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def leaves(self) -> list[Package]:
|
|
51
|
+
"""Get packages that nothing depends on (top-level packages)."""
|
|
52
|
+
return [
|
|
53
|
+
self.packages[name]
|
|
54
|
+
for name, dependents in self._reverse_edges.items()
|
|
55
|
+
if not dependents
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def get_dependencies(self, name: str) -> list[Package]:
|
|
59
|
+
"""Get direct dependencies of a package.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
name: Package name.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of packages this package depends on.
|
|
66
|
+
"""
|
|
67
|
+
deps = self._edges.get(name, set())
|
|
68
|
+
return [self.packages[d] for d in deps if d in self.packages]
|
|
69
|
+
|
|
70
|
+
def get_dependents(self, name: str) -> list[Package]:
|
|
71
|
+
"""Get packages that depend on this package.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
name: Package name.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of packages that depend on this package.
|
|
78
|
+
"""
|
|
79
|
+
deps = self._reverse_edges.get(name, set())
|
|
80
|
+
return [self.packages[d] for d in deps if d in self.packages]
|
|
81
|
+
|
|
82
|
+
def get_transitive_dependencies(self, name: str) -> list[Package]:
|
|
83
|
+
"""Get all transitive dependencies of a package.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
name: Package name.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of all packages this package transitively depends on.
|
|
90
|
+
"""
|
|
91
|
+
result: set[str] = set()
|
|
92
|
+
stack = list(self._edges.get(name, set()))
|
|
93
|
+
|
|
94
|
+
while stack:
|
|
95
|
+
dep = stack.pop()
|
|
96
|
+
if dep not in result:
|
|
97
|
+
result.add(dep)
|
|
98
|
+
stack.extend(self._edges.get(dep, set()))
|
|
99
|
+
|
|
100
|
+
return [self.packages[d] for d in result if d in self.packages]
|
|
101
|
+
|
|
102
|
+
def get_transitive_dependents(self, name: str) -> list[Package]:
|
|
103
|
+
"""Get all packages that transitively depend on this package.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
name: Package name.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of all packages that transitively depend on this package.
|
|
110
|
+
"""
|
|
111
|
+
result: set[str] = set()
|
|
112
|
+
stack = list(self._reverse_edges.get(name, set()))
|
|
113
|
+
|
|
114
|
+
while stack:
|
|
115
|
+
dep = stack.pop()
|
|
116
|
+
if dep not in result:
|
|
117
|
+
result.add(dep)
|
|
118
|
+
stack.extend(self._reverse_edges.get(dep, set()))
|
|
119
|
+
|
|
120
|
+
return [self.packages[d] for d in result if d in self.packages]
|
|
121
|
+
|
|
122
|
+
def get_affected_packages(self, changed: set[str]) -> list[Package]:
|
|
123
|
+
"""Get all packages affected by changes to the given packages.
|
|
124
|
+
|
|
125
|
+
This includes the changed packages themselves plus all their
|
|
126
|
+
transitive dependents.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
changed: Set of changed package names.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
List of all affected packages.
|
|
133
|
+
"""
|
|
134
|
+
affected: set[str] = set(changed)
|
|
135
|
+
|
|
136
|
+
for name in changed:
|
|
137
|
+
if name in self.packages:
|
|
138
|
+
dependents = self.get_transitive_dependents(name)
|
|
139
|
+
affected.update(p.name for p in dependents)
|
|
140
|
+
|
|
141
|
+
return [self.packages[n] for n in affected if n in self.packages]
|
|
142
|
+
|
|
143
|
+
def topological_order(self) -> Iterator[Package]:
|
|
144
|
+
"""Iterate packages in topological order (dependencies first).
|
|
145
|
+
|
|
146
|
+
Yields:
|
|
147
|
+
Packages in dependency order.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
CyclicDependencyError: If a cycle is detected.
|
|
151
|
+
"""
|
|
152
|
+
sorter: TopologicalSorter[str] = TopologicalSorter()
|
|
153
|
+
|
|
154
|
+
for name, deps in self._edges.items():
|
|
155
|
+
sorter.add(name, *deps)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
sorter.prepare()
|
|
159
|
+
except CycleError as e:
|
|
160
|
+
# Extract cycle from error
|
|
161
|
+
cycle = list(e.args[1]) if len(e.args) > 1 else []
|
|
162
|
+
raise CyclicDependencyError(cycle) from e
|
|
163
|
+
|
|
164
|
+
while sorter.is_active():
|
|
165
|
+
for name in sorter.get_ready():
|
|
166
|
+
yield self.packages[name]
|
|
167
|
+
sorter.done(name)
|
|
168
|
+
|
|
169
|
+
def parallel_batches(self) -> Iterator[list[Package]]:
|
|
170
|
+
"""Iterate packages in batches that can be executed in parallel.
|
|
171
|
+
|
|
172
|
+
Each batch contains packages that can run concurrently because their
|
|
173
|
+
dependencies are all satisfied.
|
|
174
|
+
|
|
175
|
+
Yields:
|
|
176
|
+
Lists of packages that can run in parallel.
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
CyclicDependencyError: If a cycle is detected.
|
|
180
|
+
"""
|
|
181
|
+
sorter: TopologicalSorter[str] = TopologicalSorter()
|
|
182
|
+
|
|
183
|
+
for name, deps in self._edges.items():
|
|
184
|
+
sorter.add(name, *deps)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
sorter.prepare()
|
|
188
|
+
except CycleError as e:
|
|
189
|
+
cycle = list(e.args[1]) if len(e.args) > 1 else []
|
|
190
|
+
raise CyclicDependencyError(cycle) from e
|
|
191
|
+
|
|
192
|
+
while sorter.is_active():
|
|
193
|
+
ready = list(sorter.get_ready())
|
|
194
|
+
if ready:
|
|
195
|
+
yield [self.packages[name] for name in ready]
|
|
196
|
+
for name in ready:
|
|
197
|
+
sorter.done(name)
|
|
198
|
+
|
|
199
|
+
def reverse_topological_order(self) -> Iterator[Package]:
|
|
200
|
+
"""Iterate packages in reverse topological order (dependents first).
|
|
201
|
+
|
|
202
|
+
Useful for operations like cleaning that should process dependents
|
|
203
|
+
before their dependencies.
|
|
204
|
+
|
|
205
|
+
Yields:
|
|
206
|
+
Packages in reverse dependency order.
|
|
207
|
+
"""
|
|
208
|
+
# Collect in list and reverse
|
|
209
|
+
ordered = list(self.topological_order())
|
|
210
|
+
yield from reversed(ordered)
|
|
211
|
+
|
|
212
|
+
def subgraph(self, names: set[str]) -> DependencyGraph:
|
|
213
|
+
"""Create a subgraph containing only the specified packages.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
names: Set of package names to include.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
New DependencyGraph with only the specified packages.
|
|
220
|
+
"""
|
|
221
|
+
filtered_packages = {name: pkg for name, pkg in self.packages.items() if name in names}
|
|
222
|
+
return DependencyGraph(packages=filtered_packages)
|
|
223
|
+
|
|
224
|
+
def to_dict(self) -> dict[str, list[str]]:
|
|
225
|
+
"""Convert graph to adjacency list representation.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Dictionary mapping package names to their dependency names.
|
|
229
|
+
"""
|
|
230
|
+
return {name: sorted(deps) for name, deps in self._edges.items()}
|
|
231
|
+
|
|
232
|
+
def __len__(self) -> int:
|
|
233
|
+
"""Number of packages in the graph."""
|
|
234
|
+
return len(self.packages)
|
|
235
|
+
|
|
236
|
+
def __contains__(self, name: str) -> bool:
|
|
237
|
+
"""Check if a package is in the graph."""
|
|
238
|
+
return name in self.packages
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Package model and metadata extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pymelos.compat import tomllib
|
|
9
|
+
from pymelos.errors import ConfigurationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Package:
|
|
14
|
+
"""Represents a package in the monorepo.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
name: Package name from pyproject.toml.
|
|
18
|
+
path: Absolute path to the package directory.
|
|
19
|
+
version: Package version string.
|
|
20
|
+
description: Package description.
|
|
21
|
+
dependencies: Set of runtime dependency package names.
|
|
22
|
+
dev_dependencies: Set of dev dependency package names.
|
|
23
|
+
workspace_dependencies: Set of local workspace package names this depends on.
|
|
24
|
+
scripts: Package-level scripts from pyproject.toml.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
path: Path
|
|
29
|
+
version: str
|
|
30
|
+
description: str | None = None
|
|
31
|
+
dependencies: frozenset[str] = field(default_factory=frozenset)
|
|
32
|
+
dev_dependencies: frozenset[str] = field(default_factory=frozenset)
|
|
33
|
+
workspace_dependencies: frozenset[str] = field(default_factory=frozenset)
|
|
34
|
+
scripts: dict[str, str] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def pyproject_path(self) -> Path:
|
|
38
|
+
"""Path to the package's pyproject.toml."""
|
|
39
|
+
return self.path / "pyproject.toml"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def src_path(self) -> Path:
|
|
43
|
+
"""Path to the package's src directory."""
|
|
44
|
+
return self.path / "src"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def tests_path(self) -> Path:
|
|
48
|
+
"""Path to the package's tests directory."""
|
|
49
|
+
return self.path / "tests"
|
|
50
|
+
|
|
51
|
+
def has_dependency(self, name: str) -> bool:
|
|
52
|
+
"""Check if this package depends on another package."""
|
|
53
|
+
return name in self.dependencies or name in self.workspace_dependencies
|
|
54
|
+
|
|
55
|
+
def has_workspace_dependency(self, other: Package) -> bool:
|
|
56
|
+
"""Check if this package depends on another workspace package."""
|
|
57
|
+
return other.name in self.workspace_dependencies
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_dependency_name(dep: str) -> str:
|
|
61
|
+
"""Extract package name from a dependency specifier.
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
"requests>=2.0" -> "requests"
|
|
65
|
+
"numpy[extra]" -> "numpy"
|
|
66
|
+
"my-pkg @ file://..." -> "my-pkg"
|
|
67
|
+
"""
|
|
68
|
+
# Handle URL-based dependencies
|
|
69
|
+
if " @ " in dep:
|
|
70
|
+
dep = dep.split(" @ ")[0]
|
|
71
|
+
|
|
72
|
+
# Handle extras
|
|
73
|
+
if "[" in dep:
|
|
74
|
+
dep = dep.split("[")[0]
|
|
75
|
+
|
|
76
|
+
# Handle version specifiers
|
|
77
|
+
for sep in (">=", "<=", "==", "!=", ">", "<", "~=", ";"):
|
|
78
|
+
if sep in dep:
|
|
79
|
+
dep = dep.split(sep)[0]
|
|
80
|
+
|
|
81
|
+
return dep.strip().lower().replace("-", "_")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_package(path: Path, workspace_packages: set[str] | None = None) -> Package:
|
|
85
|
+
"""Load a package from its directory.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
path: Path to the package directory.
|
|
89
|
+
workspace_packages: Set of known workspace package names for detecting
|
|
90
|
+
local dependencies.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Package instance with metadata from pyproject.toml.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ConfigurationError: If pyproject.toml is missing or invalid.
|
|
97
|
+
"""
|
|
98
|
+
pyproject_path = path / "pyproject.toml"
|
|
99
|
+
if not pyproject_path.is_file():
|
|
100
|
+
raise ConfigurationError(
|
|
101
|
+
"No pyproject.toml found in package directory",
|
|
102
|
+
path=path,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
with open(pyproject_path, "rb") as f:
|
|
107
|
+
data = tomllib.load(f)
|
|
108
|
+
except tomllib.TOMLDecodeError as e:
|
|
109
|
+
raise ConfigurationError(
|
|
110
|
+
f"Invalid pyproject.toml: {e}",
|
|
111
|
+
path=pyproject_path,
|
|
112
|
+
) from e
|
|
113
|
+
|
|
114
|
+
project = data.get("project", {})
|
|
115
|
+
if not project:
|
|
116
|
+
raise ConfigurationError(
|
|
117
|
+
"pyproject.toml missing [project] section",
|
|
118
|
+
path=pyproject_path,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
name = project.get("name")
|
|
122
|
+
if not name:
|
|
123
|
+
raise ConfigurationError(
|
|
124
|
+
"pyproject.toml missing project.name",
|
|
125
|
+
path=pyproject_path,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
version = project.get("version", "0.0.0")
|
|
129
|
+
description = project.get("description")
|
|
130
|
+
|
|
131
|
+
# Parse dependencies
|
|
132
|
+
raw_deps = project.get("dependencies", [])
|
|
133
|
+
dependencies = frozenset(parse_dependency_name(d) for d in raw_deps if isinstance(d, str))
|
|
134
|
+
|
|
135
|
+
# Parse dev dependencies from optional-dependencies
|
|
136
|
+
optional_deps = project.get("optional-dependencies", {})
|
|
137
|
+
dev_deps_list = optional_deps.get("dev", [])
|
|
138
|
+
dev_dependencies = frozenset(
|
|
139
|
+
parse_dependency_name(d) for d in dev_deps_list if isinstance(d, str)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Identify workspace dependencies from uv sources
|
|
143
|
+
uv_config = data.get("tool", {}).get("uv", {})
|
|
144
|
+
uv_sources = uv_config.get("sources", {})
|
|
145
|
+
workspace_deps: set[str] = set()
|
|
146
|
+
|
|
147
|
+
for dep_name, source in uv_sources.items():
|
|
148
|
+
if isinstance(source, dict) and source.get("workspace"):
|
|
149
|
+
workspace_deps.add(dep_name.lower().replace("-", "_"))
|
|
150
|
+
|
|
151
|
+
# Also check if any dependencies match known workspace packages
|
|
152
|
+
if workspace_packages:
|
|
153
|
+
for dep in dependencies | dev_dependencies:
|
|
154
|
+
normalized = dep.lower().replace("-", "_")
|
|
155
|
+
if normalized in workspace_packages:
|
|
156
|
+
workspace_deps.add(normalized)
|
|
157
|
+
|
|
158
|
+
# Parse scripts from pyproject.toml
|
|
159
|
+
scripts = project.get("scripts", {})
|
|
160
|
+
|
|
161
|
+
return Package(
|
|
162
|
+
name=name,
|
|
163
|
+
path=path.resolve(),
|
|
164
|
+
version=version,
|
|
165
|
+
description=description,
|
|
166
|
+
dependencies=dependencies,
|
|
167
|
+
dev_dependencies=dev_dependencies,
|
|
168
|
+
workspace_dependencies=frozenset(workspace_deps),
|
|
169
|
+
scripts=dict(scripts) if scripts else {},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def get_package_name_from_path(path: Path) -> str | None:
|
|
174
|
+
"""Try to extract package name from pyproject.toml without full loading.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
path: Path to the package directory.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Package name if found, None otherwise.
|
|
181
|
+
"""
|
|
182
|
+
pyproject_path = path / "pyproject.toml"
|
|
183
|
+
if not pyproject_path.is_file():
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
with open(pyproject_path, "rb") as f:
|
|
188
|
+
data = tomllib.load(f)
|
|
189
|
+
return data.get("project", {}).get("name")
|
|
190
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
191
|
+
return None
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Workspace aggregate root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pymelos.config import PyMelosConfig, load_config
|
|
10
|
+
from pymelos.errors import PackageNotFoundError
|
|
11
|
+
from pymelos.workspace.discovery import discover_packages
|
|
12
|
+
from pymelos.workspace.graph import DependencyGraph
|
|
13
|
+
from pymelos.workspace.package import Package
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Workspace:
|
|
18
|
+
"""The root aggregate for a pymelos workspace.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
root: Path to workspace root directory.
|
|
22
|
+
config: Loaded configuration from pymelos.yaml.
|
|
23
|
+
config_path: Path to the pymelos.yaml file.
|
|
24
|
+
packages: Dictionary mapping package names to Package instances.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
root: Path
|
|
28
|
+
config: PyMelosConfig
|
|
29
|
+
config_path: Path
|
|
30
|
+
packages: dict[str, Package] = field(default_factory=dict)
|
|
31
|
+
_graph: DependencyGraph | None = field(default=None, init=False, repr=False)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def discover(cls, start_path: Path | None = None) -> Workspace:
|
|
35
|
+
"""Discover and load workspace from current or specified directory.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
start_path: Directory to start searching from. Defaults to cwd.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Loaded Workspace instance.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
WorkspaceNotFoundError: If no pymelos.yaml found.
|
|
45
|
+
ConfigurationError: If configuration is invalid.
|
|
46
|
+
"""
|
|
47
|
+
if start_path is None:
|
|
48
|
+
start_path = Path.cwd()
|
|
49
|
+
|
|
50
|
+
config, config_path = load_config(start_path=start_path)
|
|
51
|
+
root = config_path.parent
|
|
52
|
+
|
|
53
|
+
packages = discover_packages(root, config)
|
|
54
|
+
|
|
55
|
+
return cls(
|
|
56
|
+
root=root,
|
|
57
|
+
config=config,
|
|
58
|
+
config_path=config_path,
|
|
59
|
+
packages=packages,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_config(cls, config_path: Path) -> Workspace:
|
|
64
|
+
"""Load workspace from explicit config file path.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
config_path: Path to pymelos.yaml.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Loaded Workspace instance.
|
|
71
|
+
"""
|
|
72
|
+
config, config_path = load_config(path=config_path)
|
|
73
|
+
root = config_path.parent
|
|
74
|
+
|
|
75
|
+
packages = discover_packages(root, config)
|
|
76
|
+
|
|
77
|
+
return cls(
|
|
78
|
+
root=root,
|
|
79
|
+
config=config,
|
|
80
|
+
config_path=config_path,
|
|
81
|
+
packages=packages,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def name(self) -> str:
|
|
86
|
+
"""Workspace name from configuration."""
|
|
87
|
+
return self.config.name
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def graph(self) -> DependencyGraph:
|
|
91
|
+
"""Dependency graph for packages."""
|
|
92
|
+
if self._graph is None:
|
|
93
|
+
self._graph = DependencyGraph(packages=self.packages)
|
|
94
|
+
return self._graph
|
|
95
|
+
|
|
96
|
+
def get_package(self, name: str) -> Package:
|
|
97
|
+
"""Get a package by name.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
name: Package name.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Package instance.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
PackageNotFoundError: If package doesn't exist.
|
|
107
|
+
"""
|
|
108
|
+
package = self.packages.get(name)
|
|
109
|
+
if package is None:
|
|
110
|
+
raise PackageNotFoundError(name, list(self.packages.keys()))
|
|
111
|
+
return package
|
|
112
|
+
|
|
113
|
+
def has_package(self, name: str) -> bool:
|
|
114
|
+
"""Check if a package exists in the workspace.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
name: Package name.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
True if package exists.
|
|
121
|
+
"""
|
|
122
|
+
return name in self.packages
|
|
123
|
+
|
|
124
|
+
def filter_packages(
|
|
125
|
+
self,
|
|
126
|
+
scope: str | None = None,
|
|
127
|
+
ignore: list[str] | None = None,
|
|
128
|
+
names: list[str] | None = None,
|
|
129
|
+
) -> list[Package]:
|
|
130
|
+
"""Filter packages by criteria.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
scope: Comma-separated names or glob patterns.
|
|
134
|
+
ignore: Patterns to exclude.
|
|
135
|
+
names: Explicit list of package names.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of matching packages.
|
|
139
|
+
"""
|
|
140
|
+
from pymelos.filters import apply_filters
|
|
141
|
+
|
|
142
|
+
return apply_filters(
|
|
143
|
+
packages=list(self.packages.values()),
|
|
144
|
+
scope=scope,
|
|
145
|
+
ignore=ignore,
|
|
146
|
+
names=names,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def topological_order(
|
|
150
|
+
self,
|
|
151
|
+
packages: list[Package] | None = None,
|
|
152
|
+
) -> Iterator[Package]:
|
|
153
|
+
"""Iterate packages in dependency order.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
packages: Subset of packages to order. Defaults to all.
|
|
157
|
+
|
|
158
|
+
Yields:
|
|
159
|
+
Packages in topological order.
|
|
160
|
+
"""
|
|
161
|
+
if packages is None:
|
|
162
|
+
yield from self.graph.topological_order()
|
|
163
|
+
else:
|
|
164
|
+
names = {p.name for p in packages}
|
|
165
|
+
subgraph = self.graph.subgraph(names)
|
|
166
|
+
yield from subgraph.topological_order()
|
|
167
|
+
|
|
168
|
+
def parallel_batches(
|
|
169
|
+
self,
|
|
170
|
+
packages: list[Package] | None = None,
|
|
171
|
+
) -> Iterator[list[Package]]:
|
|
172
|
+
"""Iterate packages in parallel-safe batches.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
packages: Subset of packages. Defaults to all.
|
|
176
|
+
|
|
177
|
+
Yields:
|
|
178
|
+
Batches of packages that can run in parallel.
|
|
179
|
+
"""
|
|
180
|
+
if packages is None:
|
|
181
|
+
yield from self.graph.parallel_batches()
|
|
182
|
+
else:
|
|
183
|
+
names = {p.name for p in packages}
|
|
184
|
+
subgraph = self.graph.subgraph(names)
|
|
185
|
+
yield from subgraph.parallel_batches()
|
|
186
|
+
|
|
187
|
+
def get_affected_packages(self, changed: list[Package]) -> list[Package]:
|
|
188
|
+
"""Get all packages affected by changes to given packages.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
changed: List of changed packages.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
All affected packages including transitive dependents.
|
|
195
|
+
"""
|
|
196
|
+
changed_names = {p.name for p in changed}
|
|
197
|
+
affected = self.graph.get_affected_packages(changed_names)
|
|
198
|
+
return list(affected)
|
|
199
|
+
|
|
200
|
+
def refresh(self) -> None:
|
|
201
|
+
"""Reload packages from disk.
|
|
202
|
+
|
|
203
|
+
Call this after making changes to pyproject.toml files.
|
|
204
|
+
"""
|
|
205
|
+
self.packages = discover_packages(self.root, self.config)
|
|
206
|
+
self._graph = None
|
|
207
|
+
|
|
208
|
+
def __len__(self) -> int:
|
|
209
|
+
"""Number of packages in workspace."""
|
|
210
|
+
return len(self.packages)
|
|
211
|
+
|
|
212
|
+
def __iter__(self) -> Iterator[Package]:
|
|
213
|
+
"""Iterate over all packages."""
|
|
214
|
+
return iter(self.packages.values())
|
|
215
|
+
|
|
216
|
+
def __contains__(self, name: str) -> bool:
|
|
217
|
+
"""Check if package name exists."""
|
|
218
|
+
return name in self.packages
|