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,249 @@
|
|
|
1
|
+
"""Semantic versioning utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BumpType(Enum):
|
|
11
|
+
"""Version bump types."""
|
|
12
|
+
|
|
13
|
+
MAJOR = auto()
|
|
14
|
+
MINOR = auto()
|
|
15
|
+
PATCH = auto()
|
|
16
|
+
NONE = auto()
|
|
17
|
+
|
|
18
|
+
def __gt__(self, other: BumpType) -> bool:
|
|
19
|
+
if not isinstance(other, BumpType):
|
|
20
|
+
return NotImplemented
|
|
21
|
+
# MAJOR > MINOR > PATCH > NONE
|
|
22
|
+
order = {BumpType.MAJOR: 3, BumpType.MINOR: 2, BumpType.PATCH: 1, BumpType.NONE: 0}
|
|
23
|
+
return order[self] > order[other]
|
|
24
|
+
|
|
25
|
+
def __lt__(self, other: BumpType) -> bool:
|
|
26
|
+
if not isinstance(other, BumpType):
|
|
27
|
+
return NotImplemented
|
|
28
|
+
return not (self > other or self == other)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# SemVer regex pattern
|
|
32
|
+
SEMVER_PATTERN = re.compile(
|
|
33
|
+
r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)"
|
|
34
|
+
r"(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
|
|
35
|
+
r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
|
|
36
|
+
r"(?:\+(?P<build>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Version:
|
|
42
|
+
"""Semantic version representation.
|
|
43
|
+
|
|
44
|
+
Follows the Semantic Versioning 2.0.0 specification.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
major: int
|
|
48
|
+
minor: int
|
|
49
|
+
patch: int
|
|
50
|
+
prerelease: str | None = None
|
|
51
|
+
build: str | None = None
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def parse(cls, version_str: str) -> Version:
|
|
55
|
+
"""Parse a version string.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
version_str: Version string like "1.2.3" or "1.2.3-alpha.1+build.123".
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Parsed Version.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If the version string is invalid.
|
|
65
|
+
"""
|
|
66
|
+
# Strip leading 'v' if present
|
|
67
|
+
if version_str.startswith("v"):
|
|
68
|
+
version_str = version_str[1:]
|
|
69
|
+
|
|
70
|
+
match = SEMVER_PATTERN.match(version_str)
|
|
71
|
+
if not match:
|
|
72
|
+
raise ValueError(f"Invalid semantic version: {version_str}")
|
|
73
|
+
|
|
74
|
+
return cls(
|
|
75
|
+
major=int(match.group("major")),
|
|
76
|
+
minor=int(match.group("minor")),
|
|
77
|
+
patch=int(match.group("patch")),
|
|
78
|
+
prerelease=match.group("prerelease"),
|
|
79
|
+
build=match.group("build"),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_parts(
|
|
84
|
+
cls,
|
|
85
|
+
major: int = 0,
|
|
86
|
+
minor: int = 0,
|
|
87
|
+
patch: int = 0,
|
|
88
|
+
prerelease: str | None = None,
|
|
89
|
+
) -> Version:
|
|
90
|
+
"""Create a version from parts."""
|
|
91
|
+
return cls(major=major, minor=minor, patch=patch, prerelease=prerelease)
|
|
92
|
+
|
|
93
|
+
def bump(
|
|
94
|
+
self,
|
|
95
|
+
bump_type: BumpType,
|
|
96
|
+
prerelease_tag: str | None = None,
|
|
97
|
+
) -> Version:
|
|
98
|
+
"""Return a new version with the specified bump applied.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
bump_type: Type of version bump.
|
|
102
|
+
prerelease_tag: Prerelease identifier (e.g., "alpha", "beta", "rc").
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
New bumped version.
|
|
106
|
+
"""
|
|
107
|
+
if bump_type == BumpType.NONE:
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
if bump_type == BumpType.MAJOR:
|
|
111
|
+
new_version = Version(self.major + 1, 0, 0)
|
|
112
|
+
elif bump_type == BumpType.MINOR:
|
|
113
|
+
new_version = Version(self.major, self.minor + 1, 0)
|
|
114
|
+
else: # PATCH
|
|
115
|
+
new_version = Version(self.major, self.minor, self.patch + 1)
|
|
116
|
+
|
|
117
|
+
if prerelease_tag:
|
|
118
|
+
return Version(
|
|
119
|
+
new_version.major,
|
|
120
|
+
new_version.minor,
|
|
121
|
+
new_version.patch,
|
|
122
|
+
prerelease=f"{prerelease_tag}.1",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return new_version
|
|
126
|
+
|
|
127
|
+
def bump_prerelease(self, tag: str | None = None) -> Version:
|
|
128
|
+
"""Bump the prerelease version.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
tag: Prerelease tag (uses existing if not provided).
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
New version with bumped prerelease.
|
|
135
|
+
"""
|
|
136
|
+
if self.prerelease:
|
|
137
|
+
# Parse existing prerelease: "alpha.1" -> "alpha.2"
|
|
138
|
+
parts = self.prerelease.rsplit(".", 1)
|
|
139
|
+
if len(parts) == 2 and parts[1].isdigit():
|
|
140
|
+
new_pre = f"{parts[0]}.{int(parts[1]) + 1}"
|
|
141
|
+
else:
|
|
142
|
+
new_pre = f"{self.prerelease}.1"
|
|
143
|
+
return Version(self.major, self.minor, self.patch, prerelease=new_pre)
|
|
144
|
+
elif tag:
|
|
145
|
+
return Version(self.major, self.minor, self.patch, prerelease=f"{tag}.1")
|
|
146
|
+
else:
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def is_prerelease(self) -> bool:
|
|
151
|
+
"""Check if this is a prerelease version."""
|
|
152
|
+
return self.prerelease is not None
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def base_version(self) -> Version:
|
|
156
|
+
"""Get the version without prerelease or build metadata."""
|
|
157
|
+
return Version(self.major, self.minor, self.patch)
|
|
158
|
+
|
|
159
|
+
def __str__(self) -> str:
|
|
160
|
+
"""Convert to version string."""
|
|
161
|
+
version = f"{self.major}.{self.minor}.{self.patch}"
|
|
162
|
+
if self.prerelease:
|
|
163
|
+
version += f"-{self.prerelease}"
|
|
164
|
+
if self.build:
|
|
165
|
+
version += f"+{self.build}"
|
|
166
|
+
return version
|
|
167
|
+
|
|
168
|
+
def __lt__(self, other: Version) -> bool:
|
|
169
|
+
"""Compare versions for sorting."""
|
|
170
|
+
if not isinstance(other, Version):
|
|
171
|
+
return NotImplemented
|
|
172
|
+
|
|
173
|
+
# Compare major.minor.patch
|
|
174
|
+
if (self.major, self.minor, self.patch) != (other.major, other.minor, other.patch):
|
|
175
|
+
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
|
|
176
|
+
|
|
177
|
+
# Prerelease versions have lower precedence
|
|
178
|
+
if self.prerelease and not other.prerelease:
|
|
179
|
+
return True
|
|
180
|
+
if not self.prerelease and other.prerelease:
|
|
181
|
+
return False
|
|
182
|
+
if self.prerelease and other.prerelease:
|
|
183
|
+
return self._compare_prerelease(self.prerelease, other.prerelease) < 0
|
|
184
|
+
|
|
185
|
+
return False
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _compare_prerelease(a: str, b: str) -> int:
|
|
189
|
+
"""Compare prerelease identifiers."""
|
|
190
|
+
a_parts = a.split(".")
|
|
191
|
+
b_parts = b.split(".")
|
|
192
|
+
|
|
193
|
+
for a_part, b_part in zip(a_parts, b_parts, strict=False):
|
|
194
|
+
# Numeric identifiers have lower precedence than alphanumeric
|
|
195
|
+
a_is_num = a_part.isdigit()
|
|
196
|
+
b_is_num = b_part.isdigit()
|
|
197
|
+
|
|
198
|
+
if a_is_num and b_is_num:
|
|
199
|
+
diff = int(a_part) - int(b_part)
|
|
200
|
+
if diff != 0:
|
|
201
|
+
return diff
|
|
202
|
+
elif a_is_num:
|
|
203
|
+
return -1
|
|
204
|
+
elif b_is_num:
|
|
205
|
+
return 1
|
|
206
|
+
else:
|
|
207
|
+
if a_part < b_part:
|
|
208
|
+
return -1
|
|
209
|
+
if a_part > b_part:
|
|
210
|
+
return 1
|
|
211
|
+
|
|
212
|
+
# Shorter prerelease has lower precedence
|
|
213
|
+
return len(a_parts) - len(b_parts)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def is_valid_semver(version_str: str) -> bool:
|
|
217
|
+
"""Check if a string is a valid semantic version.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
version_str: Version string to check.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
True if valid semver.
|
|
224
|
+
"""
|
|
225
|
+
try:
|
|
226
|
+
Version.parse(version_str)
|
|
227
|
+
return True
|
|
228
|
+
except ValueError:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def compare_versions(v1: str, v2: str) -> int:
|
|
233
|
+
"""Compare two version strings.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
v1: First version.
|
|
237
|
+
v2: Second version.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
-1 if v1 < v2, 0 if equal, 1 if v1 > v2.
|
|
241
|
+
"""
|
|
242
|
+
ver1 = Version.parse(v1)
|
|
243
|
+
ver2 = Version.parse(v2)
|
|
244
|
+
|
|
245
|
+
if ver1 < ver2:
|
|
246
|
+
return -1
|
|
247
|
+
if ver2 < ver1:
|
|
248
|
+
return 1
|
|
249
|
+
return 0
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Version file updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pymelos.compat import tomllib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def update_pyproject_version(path: Path, new_version: str) -> None:
|
|
12
|
+
"""Update version in pyproject.toml.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
path: Path to pyproject.toml.
|
|
16
|
+
new_version: New version string.
|
|
17
|
+
"""
|
|
18
|
+
content = path.read_text(encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
# Update version in [project] section
|
|
21
|
+
# Match: version = "x.y.z"
|
|
22
|
+
pattern = r'(version\s*=\s*["\'])[\d.]+(-[\w.]+)?(["\'])'
|
|
23
|
+
replacement = rf"\g<1>{new_version}\g<3>"
|
|
24
|
+
|
|
25
|
+
new_content = re.sub(pattern, replacement, content, count=1)
|
|
26
|
+
|
|
27
|
+
if new_content == content:
|
|
28
|
+
raise ValueError(f"Could not find version in {path}")
|
|
29
|
+
|
|
30
|
+
path.write_text(new_content, encoding="utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_pyproject_version(path: Path) -> str:
|
|
34
|
+
"""Get version from pyproject.toml.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
path: Path to pyproject.toml.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Version string.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If version not found.
|
|
44
|
+
"""
|
|
45
|
+
with open(path, "rb") as f:
|
|
46
|
+
data = tomllib.load(f)
|
|
47
|
+
|
|
48
|
+
version = data.get("project", {}).get("version")
|
|
49
|
+
if not version:
|
|
50
|
+
raise ValueError(f"No version found in {path}")
|
|
51
|
+
return version
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def update_init_version(path: Path, new_version: str) -> bool:
|
|
55
|
+
"""Update __version__ in __init__.py if it exists.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
path: Path to __init__.py.
|
|
59
|
+
new_version: New version string.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True if updated, False if no __version__ found.
|
|
63
|
+
"""
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
content = path.read_text(encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
# Match: __version__ = "x.y.z"
|
|
70
|
+
pattern = r'(__version__\s*=\s*["\'])[\d.]+(-[\w.]+)?(["\'])'
|
|
71
|
+
replacement = rf"\g<1>{new_version}\g<3>"
|
|
72
|
+
|
|
73
|
+
new_content = re.sub(pattern, replacement, content)
|
|
74
|
+
|
|
75
|
+
if new_content == content:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
path.write_text(new_content, encoding="utf-8")
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_version_files(package_path: Path) -> list[Path]:
|
|
83
|
+
"""Find files that might contain version information.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
package_path: Path to package directory.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of paths to version files.
|
|
90
|
+
"""
|
|
91
|
+
files: list[Path] = []
|
|
92
|
+
|
|
93
|
+
# pyproject.toml
|
|
94
|
+
pyproject = package_path / "pyproject.toml"
|
|
95
|
+
if pyproject.exists():
|
|
96
|
+
files.append(pyproject)
|
|
97
|
+
|
|
98
|
+
# src/<package>/__init__.py
|
|
99
|
+
src_dir = package_path / "src"
|
|
100
|
+
if src_dir.is_dir():
|
|
101
|
+
for init_file in src_dir.glob("*/__init__.py"):
|
|
102
|
+
files.append(init_file)
|
|
103
|
+
|
|
104
|
+
# Direct __init__.py
|
|
105
|
+
for init_file in package_path.glob("*/__init__.py"):
|
|
106
|
+
if init_file.parent.name != "tests":
|
|
107
|
+
files.append(init_file)
|
|
108
|
+
|
|
109
|
+
return files
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def update_all_versions(
|
|
113
|
+
package_path: Path,
|
|
114
|
+
package_name: str,
|
|
115
|
+
new_version: str,
|
|
116
|
+
) -> list[Path]:
|
|
117
|
+
"""Update version in all relevant files.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
package_path: Path to package directory.
|
|
121
|
+
package_name: Package name (for finding __init__.py).
|
|
122
|
+
new_version: New version string.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List of files that were updated.
|
|
126
|
+
"""
|
|
127
|
+
updated: list[Path] = []
|
|
128
|
+
|
|
129
|
+
# Update pyproject.toml
|
|
130
|
+
pyproject = package_path / "pyproject.toml"
|
|
131
|
+
if pyproject.exists():
|
|
132
|
+
update_pyproject_version(pyproject, new_version)
|
|
133
|
+
updated.append(pyproject)
|
|
134
|
+
|
|
135
|
+
# Update __init__.py files
|
|
136
|
+
# Try src/<package>/__init__.py first
|
|
137
|
+
src_init = package_path / "src" / package_name.replace("-", "_") / "__init__.py"
|
|
138
|
+
if update_init_version(src_init, new_version):
|
|
139
|
+
updated.append(src_init)
|
|
140
|
+
|
|
141
|
+
# Try <package>/__init__.py
|
|
142
|
+
direct_init = package_path / package_name.replace("-", "_") / "__init__.py"
|
|
143
|
+
if update_init_version(direct_init, new_version):
|
|
144
|
+
updated.append(direct_init)
|
|
145
|
+
|
|
146
|
+
return updated
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Workspace discovery and management."""
|
|
2
|
+
|
|
3
|
+
from pymelos.workspace.discovery import (
|
|
4
|
+
discover_packages,
|
|
5
|
+
expand_package_patterns,
|
|
6
|
+
find_package_at_path,
|
|
7
|
+
is_workspace_root,
|
|
8
|
+
)
|
|
9
|
+
from pymelos.workspace.graph import DependencyGraph
|
|
10
|
+
from pymelos.workspace.package import (
|
|
11
|
+
Package,
|
|
12
|
+
get_package_name_from_path,
|
|
13
|
+
load_package,
|
|
14
|
+
parse_dependency_name,
|
|
15
|
+
)
|
|
16
|
+
from pymelos.workspace.workspace import Workspace
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
# Workspace
|
|
20
|
+
"Workspace",
|
|
21
|
+
# Package
|
|
22
|
+
"Package",
|
|
23
|
+
"load_package",
|
|
24
|
+
"parse_dependency_name",
|
|
25
|
+
"get_package_name_from_path",
|
|
26
|
+
# Graph
|
|
27
|
+
"DependencyGraph",
|
|
28
|
+
# Discovery
|
|
29
|
+
"discover_packages",
|
|
30
|
+
"expand_package_patterns",
|
|
31
|
+
"find_package_at_path",
|
|
32
|
+
"is_workspace_root",
|
|
33
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Workspace and package discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pymelos.config import PyMelosConfig
|
|
9
|
+
from pymelos.workspace.package import Package, get_package_name_from_path, load_package
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def expand_package_patterns(
|
|
13
|
+
root: Path,
|
|
14
|
+
patterns: list[str],
|
|
15
|
+
ignore_patterns: list[str] | None = None,
|
|
16
|
+
) -> list[Path]:
|
|
17
|
+
"""Expand glob patterns to find package directories.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
root: Workspace root directory.
|
|
21
|
+
patterns: Glob patterns like ["packages/*", "libs/*"].
|
|
22
|
+
ignore_patterns: Patterns to exclude.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
List of paths to package directories (containing pyproject.toml).
|
|
26
|
+
"""
|
|
27
|
+
ignore_patterns = ignore_patterns or []
|
|
28
|
+
package_paths: list[Path] = []
|
|
29
|
+
|
|
30
|
+
for pattern in patterns:
|
|
31
|
+
# Handle both relative and absolute patterns
|
|
32
|
+
base_pattern = pattern[1:] if pattern.startswith("/") else pattern
|
|
33
|
+
|
|
34
|
+
# Expand the glob pattern
|
|
35
|
+
for path in root.glob(base_pattern):
|
|
36
|
+
if not path.is_dir():
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
# Check if it has a pyproject.toml
|
|
40
|
+
if not (path / "pyproject.toml").is_file():
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Check ignore patterns
|
|
44
|
+
rel_path = path.relative_to(root)
|
|
45
|
+
rel_str = str(rel_path)
|
|
46
|
+
|
|
47
|
+
ignored = False
|
|
48
|
+
for ignore in ignore_patterns:
|
|
49
|
+
if fnmatch.fnmatch(rel_str, ignore) or fnmatch.fnmatch(path.name, ignore):
|
|
50
|
+
ignored = True
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
if not ignored:
|
|
54
|
+
package_paths.append(path)
|
|
55
|
+
|
|
56
|
+
# Remove duplicates while preserving order
|
|
57
|
+
seen: set[Path] = set()
|
|
58
|
+
unique_paths: list[Path] = []
|
|
59
|
+
for p in package_paths:
|
|
60
|
+
resolved = p.resolve()
|
|
61
|
+
if resolved not in seen:
|
|
62
|
+
seen.add(resolved)
|
|
63
|
+
unique_paths.append(resolved)
|
|
64
|
+
|
|
65
|
+
return sorted(unique_paths, key=lambda p: p.name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def discover_packages(
|
|
69
|
+
root: Path,
|
|
70
|
+
config: PyMelosConfig,
|
|
71
|
+
) -> dict[str, Package]:
|
|
72
|
+
"""Discover all packages in the workspace.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
root: Workspace root directory.
|
|
76
|
+
config: Workspace configuration.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Dictionary mapping package names to Package instances.
|
|
80
|
+
"""
|
|
81
|
+
# First pass: find all package paths and their names
|
|
82
|
+
package_paths = expand_package_patterns(root, config.packages, config.ignore)
|
|
83
|
+
|
|
84
|
+
# Get all package names for workspace dependency detection
|
|
85
|
+
workspace_package_names: set[str] = set()
|
|
86
|
+
for path in package_paths:
|
|
87
|
+
name = get_package_name_from_path(path)
|
|
88
|
+
if name:
|
|
89
|
+
# Normalize name for comparison
|
|
90
|
+
workspace_package_names.add(name.lower().replace("-", "_"))
|
|
91
|
+
|
|
92
|
+
# Second pass: fully load all packages
|
|
93
|
+
packages: dict[str, Package] = {}
|
|
94
|
+
for path in package_paths:
|
|
95
|
+
package = load_package(path, workspace_package_names)
|
|
96
|
+
packages[package.name] = package
|
|
97
|
+
|
|
98
|
+
return packages
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_package_at_path(
|
|
102
|
+
root: Path,
|
|
103
|
+
config: PyMelosConfig,
|
|
104
|
+
target_path: Path,
|
|
105
|
+
) -> Package | None:
|
|
106
|
+
"""Find the package that contains a given path.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
root: Workspace root directory.
|
|
110
|
+
config: Workspace configuration.
|
|
111
|
+
target_path: Path to search for.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Package that contains the path, or None if not found.
|
|
115
|
+
"""
|
|
116
|
+
target_path = target_path.resolve()
|
|
117
|
+
packages = discover_packages(root, config)
|
|
118
|
+
|
|
119
|
+
for package in packages.values():
|
|
120
|
+
try:
|
|
121
|
+
target_path.relative_to(package.path)
|
|
122
|
+
return package
|
|
123
|
+
except ValueError:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_workspace_root(path: Path) -> bool:
|
|
130
|
+
"""Check if a path is a workspace root (contains pymelos.yaml).
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
path: Path to check.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if path contains pymelos.yaml or pymelos.yml.
|
|
137
|
+
"""
|
|
138
|
+
return (path / "pymelos.yaml").is_file() or (path / "pymelos.yml").is_file()
|