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,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()