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
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
|