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
pymelos/git/tags.py ADDED
@@ -0,0 +1,242 @@
1
+ """Git tag operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from pymelos.git.repo import run_git_command
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Tag:
14
+ """Represents a git tag.
15
+
16
+ Attributes:
17
+ name: Tag name.
18
+ sha: Commit SHA the tag points to.
19
+ is_annotated: Whether this is an annotated tag.
20
+ """
21
+
22
+ name: str
23
+ sha: str
24
+ is_annotated: bool = False
25
+
26
+
27
+ def list_tags(cwd: Path, pattern: str | None = None) -> list[Tag]:
28
+ """List all tags in the repository.
29
+
30
+ Args:
31
+ cwd: Working directory.
32
+ pattern: Optional glob pattern to filter tags.
33
+
34
+ Returns:
35
+ List of tags sorted by version (if semver) or name.
36
+ """
37
+ args = ["tag", "-l"]
38
+ if pattern:
39
+ args.append(pattern)
40
+
41
+ # Also get the commit SHA for each tag
42
+ args.extend(["--format=%(refname:short)%00%(objectname:short)%00%(objecttype)"])
43
+
44
+ result = run_git_command(args, cwd=cwd)
45
+
46
+ tags: list[Tag] = []
47
+ for line in result.stdout.strip().split("\n"):
48
+ if not line:
49
+ continue
50
+ parts = line.split("\x00")
51
+ if len(parts) >= 3:
52
+ name, sha, obj_type = parts[:3]
53
+ tags.append(
54
+ Tag(
55
+ name=name,
56
+ sha=sha,
57
+ is_annotated=obj_type == "tag",
58
+ )
59
+ )
60
+
61
+ return tags
62
+
63
+
64
+ def get_latest_tag(
65
+ cwd: Path,
66
+ pattern: str | None = None,
67
+ prefix: str | None = None,
68
+ ) -> Tag | None:
69
+ """Get the latest tag, optionally matching a pattern.
70
+
71
+ Args:
72
+ cwd: Working directory.
73
+ pattern: Glob pattern for tag names.
74
+ prefix: Tag prefix to filter by (e.g., "v" or "pkg-name@").
75
+
76
+ Returns:
77
+ Latest tag or None if no tags found.
78
+ """
79
+ # Use git describe to find the latest tag
80
+ args = ["describe", "--tags", "--abbrev=0"]
81
+ if pattern:
82
+ args.extend(["--match", pattern])
83
+
84
+ result = run_git_command(args, cwd=cwd, check=False)
85
+ if result.returncode != 0:
86
+ return None
87
+
88
+ tag_name = result.stdout.strip()
89
+ if not tag_name:
90
+ return None
91
+
92
+ if prefix and not tag_name.startswith(prefix):
93
+ return None
94
+
95
+ # Get the SHA
96
+ sha_result = run_git_command(["rev-parse", f"{tag_name}^{{commit}}"], cwd=cwd)
97
+ sha = sha_result.stdout.strip()
98
+
99
+ return Tag(name=tag_name, sha=sha)
100
+
101
+
102
+ def get_tags_for_commit(cwd: Path, commit: str) -> list[Tag]:
103
+ """Get all tags pointing to a specific commit.
104
+
105
+ Args:
106
+ cwd: Working directory.
107
+ commit: Commit SHA.
108
+
109
+ Returns:
110
+ List of tags pointing to the commit.
111
+ """
112
+ result = run_git_command(["tag", "--points-at", commit], cwd=cwd)
113
+
114
+ tags: list[Tag] = []
115
+ for name in result.stdout.strip().split("\n"):
116
+ if name:
117
+ tags.append(Tag(name=name, sha=commit))
118
+
119
+ return tags
120
+
121
+
122
+ def create_tag(
123
+ cwd: Path,
124
+ name: str,
125
+ message: str | None = None,
126
+ *,
127
+ commit: str | None = None,
128
+ ) -> Tag:
129
+ """Create a new git tag.
130
+
131
+ Args:
132
+ cwd: Working directory.
133
+ name: Tag name.
134
+ message: Tag message (creates annotated tag).
135
+ commit: Commit to tag. Defaults to HEAD.
136
+
137
+ Returns:
138
+ Created tag.
139
+ """
140
+ args = ["tag"]
141
+ if message:
142
+ args.extend(["-a", "-m", message])
143
+ args.append(name)
144
+ if commit:
145
+ args.append(commit)
146
+
147
+ run_git_command(args, cwd=cwd)
148
+
149
+ # Get the SHA
150
+ sha_result = run_git_command(["rev-parse", f"{name}^{{commit}}"], cwd=cwd)
151
+ sha = sha_result.stdout.strip()
152
+
153
+ return Tag(name=name, sha=sha, is_annotated=bool(message))
154
+
155
+
156
+ def delete_tag(cwd: Path, name: str) -> None:
157
+ """Delete a git tag.
158
+
159
+ Args:
160
+ cwd: Working directory.
161
+ name: Tag name to delete.
162
+ """
163
+ run_git_command(["tag", "-d", name], cwd=cwd)
164
+
165
+
166
+ # Pattern to extract version from various tag formats
167
+ _VERSION_PATTERNS = [
168
+ re.compile(r"^v?(\d+\.\d+\.\d+.*)$"), # v1.2.3 or 1.2.3
169
+ re.compile(r"^.+@(\d+\.\d+\.\d+.*)$"), # pkg@1.2.3
170
+ ]
171
+
172
+
173
+ def parse_version_from_tag(tag: str, prefix: str = "") -> str | None:
174
+ """Extract version string from a tag name.
175
+
176
+ Args:
177
+ tag: Tag name (e.g., "v1.2.3" or "pkg@1.2.3").
178
+ prefix: Expected prefix (e.g., "v" or "pkg@").
179
+
180
+ Returns:
181
+ Version string or None if not a version tag.
182
+ """
183
+ if prefix and tag.startswith(prefix):
184
+ return tag[len(prefix) :]
185
+
186
+ for pattern in _VERSION_PATTERNS:
187
+ if match := pattern.match(tag):
188
+ return match.group(1)
189
+
190
+ return None
191
+
192
+
193
+ def get_package_tags(cwd: Path, package_name: str) -> list[Tag]:
194
+ """Get all tags for a specific package.
195
+
196
+ Uses the tag format: {package_name}@{version}
197
+
198
+ Args:
199
+ cwd: Working directory.
200
+ package_name: Package name.
201
+
202
+ Returns:
203
+ List of tags for the package.
204
+ """
205
+ pattern = f"{package_name}@*"
206
+ return list_tags(cwd, pattern=pattern)
207
+
208
+
209
+ _SEMVER_PATTERN = re.compile(r"(\d+)\.(\d+)\.(\d+)(.*)?")
210
+
211
+
212
+ def _parse_version_tuple(version: str | None, fallback: str) -> tuple[int, int, int, str]:
213
+ """Parse version string into sortable tuple."""
214
+ if not version:
215
+ return (0, 0, 0, fallback)
216
+
217
+ if match := _SEMVER_PATTERN.match(version):
218
+ major, minor, patch, rest = match.groups()
219
+ return (int(major), int(minor), int(patch), rest or "")
220
+
221
+ return (0, 0, 0, fallback)
222
+
223
+
224
+ def get_latest_package_tag(cwd: Path, package_name: str) -> Tag | None:
225
+ """Get the latest tag for a specific package.
226
+
227
+ Args:
228
+ cwd: Working directory.
229
+ package_name: Package name.
230
+
231
+ Returns:
232
+ Latest tag for the package or None.
233
+ """
234
+ tags = get_package_tags(cwd, package_name)
235
+ if not tags:
236
+ return None
237
+
238
+ prefix = f"{package_name}@"
239
+ return max(
240
+ tags,
241
+ key=lambda t: _parse_version_tuple(parse_version_from_tag(t.name, prefix), t.name),
242
+ )
pymelos/py.typed ADDED
File without changes
pymelos/types.py ADDED
@@ -0,0 +1,16 @@
1
+ """Common type definitions for pymelos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TypeAlias
7
+
8
+ # Path-related types
9
+ PathLike: TypeAlias = str | Path
10
+
11
+ # Filter types
12
+ ScopePattern: TypeAlias = str # Glob pattern like "core,api" or "*-lib"
13
+ GitRef: TypeAlias = str # Git reference like "main", "v1.0.0", "HEAD~5"
14
+
15
+ # Package name type
16
+ PackageName: TypeAlias = str
pymelos/uv/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ """uv CLI integration."""
2
+
3
+ from pymelos.uv.client import (
4
+ check_uv_installed,
5
+ get_uv_executable,
6
+ get_uv_version,
7
+ run_uv,
8
+ run_uv_async,
9
+ )
10
+ from pymelos.uv.publish import (
11
+ build,
12
+ build_and_publish,
13
+ check_publishable,
14
+ publish,
15
+ )
16
+ from pymelos.uv.sync import (
17
+ add_dependency,
18
+ lock,
19
+ pip_list,
20
+ remove_dependency,
21
+ sync,
22
+ sync_async,
23
+ )
24
+
25
+ __all__ = [
26
+ # Client
27
+ "get_uv_executable",
28
+ "run_uv",
29
+ "run_uv_async",
30
+ "get_uv_version",
31
+ "check_uv_installed",
32
+ # Sync
33
+ "sync",
34
+ "sync_async",
35
+ "lock",
36
+ "add_dependency",
37
+ "remove_dependency",
38
+ "pip_list",
39
+ # Publish
40
+ "build",
41
+ "publish",
42
+ "build_and_publish",
43
+ "check_publishable",
44
+ ]
pymelos/uv/client.py ADDED
@@ -0,0 +1,167 @@
1
+ """uv CLI wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from pymelos.errors import ExecutionError
11
+
12
+
13
+ def get_uv_executable() -> str:
14
+ """Get the path to the uv executable.
15
+
16
+ Returns:
17
+ Path to uv executable.
18
+
19
+ Raises:
20
+ ExecutionError: If uv is not installed.
21
+ """
22
+ # Check if uv is in PATH
23
+ result = subprocess.run(
24
+ ["which", "uv"],
25
+ capture_output=True,
26
+ text=True,
27
+ check=False,
28
+ )
29
+ if result.returncode == 0:
30
+ return result.stdout.strip()
31
+
32
+ # Check common locations
33
+ common_paths = [
34
+ Path.home() / ".cargo" / "bin" / "uv",
35
+ Path.home() / ".local" / "bin" / "uv",
36
+ Path("/usr/local/bin/uv"),
37
+ ]
38
+
39
+ for path in common_paths:
40
+ if path.exists():
41
+ return str(path)
42
+
43
+ raise ExecutionError(
44
+ "uv is not installed. Install it with: curl -LsSf https://astral.sh/uv/install.sh | sh"
45
+ )
46
+
47
+
48
+ def run_uv(
49
+ args: list[str],
50
+ cwd: Path | None = None,
51
+ *,
52
+ env: dict[str, str] | None = None,
53
+ check: bool = True,
54
+ ) -> subprocess.CompletedProcess[str]:
55
+ """Run a uv command synchronously.
56
+
57
+ Args:
58
+ args: Command arguments (without 'uv').
59
+ cwd: Working directory.
60
+ env: Additional environment variables.
61
+ check: Raise on non-zero exit.
62
+
63
+ Returns:
64
+ Completed process.
65
+
66
+ Raises:
67
+ ExecutionError: If command fails and check is True.
68
+ """
69
+ uv = get_uv_executable()
70
+ cmd = [uv] + args
71
+
72
+ run_env = os.environ.copy()
73
+ if env:
74
+ run_env.update(env)
75
+
76
+ result = subprocess.run(
77
+ cmd,
78
+ cwd=cwd,
79
+ capture_output=True,
80
+ text=True,
81
+ env=run_env,
82
+ check=False,
83
+ )
84
+
85
+ if check and result.returncode != 0:
86
+ raise ExecutionError(
87
+ f"uv command failed: {' '.join(args)}\n{result.stderr}",
88
+ exit_code=result.returncode,
89
+ stderr=result.stderr,
90
+ )
91
+
92
+ return result
93
+
94
+
95
+ async def run_uv_async(
96
+ args: list[str],
97
+ cwd: Path | None = None,
98
+ *,
99
+ env: dict[str, str] | None = None,
100
+ check: bool = True,
101
+ ) -> tuple[int, str, str]:
102
+ """Run a uv command asynchronously.
103
+
104
+ Args:
105
+ args: Command arguments (without 'uv').
106
+ cwd: Working directory.
107
+ env: Additional environment variables.
108
+ check: Raise on non-zero exit.
109
+
110
+ Returns:
111
+ Tuple of (exit_code, stdout, stderr).
112
+
113
+ Raises:
114
+ ExecutionError: If command fails and check is True.
115
+ """
116
+ uv = get_uv_executable()
117
+ cmd = [uv] + args
118
+
119
+ run_env = os.environ.copy()
120
+ if env:
121
+ run_env.update(env)
122
+
123
+ process = await asyncio.create_subprocess_exec(
124
+ *cmd,
125
+ cwd=cwd,
126
+ stdout=asyncio.subprocess.PIPE,
127
+ stderr=asyncio.subprocess.PIPE,
128
+ env=run_env,
129
+ )
130
+
131
+ stdout_bytes, stderr_bytes = await process.communicate()
132
+ stdout = stdout_bytes.decode("utf-8", errors="replace")
133
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
134
+ exit_code = process.returncode or 0
135
+
136
+ if check and exit_code != 0:
137
+ raise ExecutionError(
138
+ f"uv command failed: {' '.join(args)}\n{stderr}",
139
+ exit_code=exit_code,
140
+ stderr=stderr,
141
+ )
142
+
143
+ return exit_code, stdout, stderr
144
+
145
+
146
+ def get_uv_version() -> str:
147
+ """Get the installed uv version.
148
+
149
+ Returns:
150
+ Version string.
151
+ """
152
+ result = run_uv(["--version"])
153
+ # "uv 0.5.10" -> "0.5.10"
154
+ return result.stdout.strip().split()[-1]
155
+
156
+
157
+ def check_uv_installed() -> bool:
158
+ """Check if uv is installed and accessible.
159
+
160
+ Returns:
161
+ True if uv is installed.
162
+ """
163
+ try:
164
+ get_uv_executable()
165
+ return True
166
+ except ExecutionError:
167
+ return False
pymelos/uv/publish.py ADDED
@@ -0,0 +1,162 @@
1
+ """uv build and publish operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from pymelos.errors import PublishError
8
+ from pymelos.uv.client import run_uv
9
+
10
+
11
+ def build(
12
+ cwd: Path,
13
+ *,
14
+ sdist: bool = True,
15
+ wheel: bool = True,
16
+ out_dir: Path | None = None,
17
+ ) -> Path:
18
+ """Build package distributions.
19
+
20
+ Args:
21
+ cwd: Package directory.
22
+ sdist: Build source distribution.
23
+ wheel: Build wheel.
24
+ out_dir: Output directory (defaults to dist/).
25
+
26
+ Returns:
27
+ Path to dist directory.
28
+ """
29
+ args = ["build"]
30
+
31
+ if not sdist:
32
+ args.append("--no-sdist")
33
+ if not wheel:
34
+ args.append("--no-wheel")
35
+ if out_dir:
36
+ args.extend(["--out-dir", str(out_dir)])
37
+
38
+ run_uv(args, cwd=cwd)
39
+
40
+ return out_dir or (cwd / "dist")
41
+
42
+
43
+ def publish(
44
+ cwd: Path,
45
+ *,
46
+ repository: str | None = None,
47
+ token: str | None = None,
48
+ username: str | None = None,
49
+ password: str | None = None,
50
+ dist_dir: Path | None = None,
51
+ ) -> None:
52
+ """Publish package to a registry.
53
+
54
+ Args:
55
+ cwd: Package directory.
56
+ repository: Repository URL.
57
+ token: API token for authentication.
58
+ username: Username for authentication.
59
+ password: Password for authentication.
60
+ dist_dir: Directory containing distributions.
61
+
62
+ Raises:
63
+ PublishError: If publish fails.
64
+ """
65
+ args = ["publish"]
66
+
67
+ if repository:
68
+ args.extend(["--publish-url", repository])
69
+ if token:
70
+ args.extend(["--token", token])
71
+ if username:
72
+ args.extend(["--username", username])
73
+ if password:
74
+ args.extend(["--password", password])
75
+
76
+ # Add distribution files
77
+ dist = dist_dir or (cwd / "dist")
78
+ if not dist.exists():
79
+ raise PublishError(
80
+ f"Distribution directory not found: {dist}. Run 'uv build' first.",
81
+ package_name=cwd.name,
82
+ )
83
+
84
+ # Find distribution files
85
+ dists = list(dist.glob("*.tar.gz")) + list(dist.glob("*.whl"))
86
+ if not dists:
87
+ raise PublishError(
88
+ f"No distributions found in {dist}",
89
+ package_name=cwd.name,
90
+ )
91
+
92
+ # Add all distribution files
93
+ args.extend(str(d) for d in dists)
94
+
95
+ try:
96
+ run_uv(args, cwd=cwd)
97
+ except Exception as e:
98
+ raise PublishError(str(e), package_name=cwd.name, registry=repository) from e
99
+
100
+
101
+ def build_and_publish(
102
+ cwd: Path,
103
+ *,
104
+ repository: str | None = None,
105
+ token: str | None = None,
106
+ clean_first: bool = True,
107
+ ) -> None:
108
+ """Build and publish a package.
109
+
110
+ Args:
111
+ cwd: Package directory.
112
+ repository: Repository URL.
113
+ token: API token.
114
+ clean_first: Remove existing dist/ before building.
115
+ """
116
+ dist_dir = cwd / "dist"
117
+
118
+ if clean_first and dist_dir.exists():
119
+ import shutil
120
+
121
+ shutil.rmtree(dist_dir)
122
+
123
+ build(cwd)
124
+ publish(cwd, repository=repository, token=token, dist_dir=dist_dir)
125
+
126
+
127
+ def check_publishable(cwd: Path) -> list[str]:
128
+ """Check if a package can be published.
129
+
130
+ Args:
131
+ cwd: Package directory.
132
+
133
+ Returns:
134
+ List of issues (empty if publishable).
135
+ """
136
+ issues: list[str] = []
137
+
138
+ pyproject = cwd / "pyproject.toml"
139
+ if not pyproject.exists():
140
+ issues.append("No pyproject.toml found")
141
+ return issues
142
+
143
+ from pymelos.compat import tomllib
144
+
145
+ with open(pyproject, "rb") as f:
146
+ data = tomllib.load(f)
147
+
148
+ project = data.get("project", {})
149
+
150
+ # Required fields for publishing
151
+ required = ["name", "version", "description"]
152
+ for field in required:
153
+ if not project.get(field):
154
+ issues.append(f"Missing required field: project.{field}")
155
+
156
+ # Recommended fields
157
+ if not project.get("readme"):
158
+ issues.append("Missing recommended field: project.readme")
159
+ if not project.get("license"):
160
+ issues.append("Missing recommended field: project.license")
161
+
162
+ return issues