shipcli 0.0.1__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.
shipcli/publisher.py ADDED
@@ -0,0 +1,432 @@
1
+ """shipcli publish 发布能力。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import importlib.util
7
+ import json
8
+ import mimetypes
9
+ import os
10
+ import subprocess
11
+ import sys
12
+ import urllib.error
13
+ import urllib.parse
14
+ import urllib.request
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from shipcli.config import load_config
20
+
21
+ @dataclass(frozen=True)
22
+ class LocalReleaseProduct:
23
+ """描述本地已构建好的 release 分发产物。"""
24
+
25
+ app_name: str
26
+ version: str
27
+ version_dir: Path
28
+ dist_dir: Path
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class GitHubPublishResult:
33
+ """描述 GitHub Release 发布结果。"""
34
+
35
+ version: str
36
+ release_url: str
37
+ asset_paths: tuple[Path, ...]
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PyPIPublishResult:
42
+ """描述 PyPI 发布结果。"""
43
+
44
+ version: str
45
+ distribution_paths: tuple[Path, ...]
46
+
47
+
48
+ def is_git_repo(project_root: Path) -> bool:
49
+ """判断项目根目录是否为 git 仓库。"""
50
+ return (project_root / ".git").is_dir()
51
+
52
+
53
+ def git_working_tree_clean(project_root: Path) -> bool:
54
+ """判断 git 工作区是否干净(无未提交改动,含未跟踪文件)。"""
55
+ result = subprocess.run(
56
+ ["git", "status", "--porcelain"],
57
+ cwd=project_root,
58
+ capture_output=True,
59
+ text=True,
60
+ check=False,
61
+ )
62
+ if result.returncode != 0:
63
+ return False
64
+ return result.stdout.strip() == ""
65
+
66
+
67
+ def git_commit_all(project_root: Path, message: str) -> bool:
68
+ """提交工作区全部改动;无改动时返回 False。"""
69
+ if git_working_tree_clean(project_root):
70
+ return False
71
+ subprocess.run(
72
+ ["git", "add", "-A"],
73
+ cwd=project_root,
74
+ check=True,
75
+ capture_output=True,
76
+ text=True,
77
+ )
78
+ subprocess.run(
79
+ ["git", "commit", "-m", message],
80
+ cwd=project_root,
81
+ check=True,
82
+ capture_output=True,
83
+ text=True,
84
+ )
85
+ return True
86
+
87
+
88
+ def git_tag_exists(project_root: Path, tag: str) -> bool:
89
+ """判断本地或远程是否已存在指定 git tag。"""
90
+ result = subprocess.run(
91
+ ["git", "tag", "-l", tag],
92
+ cwd=project_root,
93
+ capture_output=True,
94
+ text=True,
95
+ check=False,
96
+ )
97
+ if result.returncode != 0:
98
+ return False
99
+ return tag in result.stdout.split()
100
+
101
+
102
+ def create_git_tag(project_root: Path, tag: str, push: bool = True) -> bool:
103
+ """为当前 HEAD 创建 git tag,已存在则跳过;返回是否实际新建。"""
104
+ if git_tag_exists(project_root, tag):
105
+ return False
106
+ subprocess.run(
107
+ ["git", "tag", tag],
108
+ cwd=project_root,
109
+ check=True,
110
+ capture_output=True,
111
+ text=True,
112
+ )
113
+ if push:
114
+ subprocess.run(
115
+ ["git", "push", "origin", tag],
116
+ cwd=project_root,
117
+ check=True,
118
+ capture_output=True,
119
+ text=True,
120
+ )
121
+ return True
122
+
123
+
124
+ def resolve_release_version(project_root: Path, version: str | None = None) -> str:
125
+ """解析要发布的 release 版本,并阻止误发 dev 版本。"""
126
+ config = load_config(project_root)
127
+ resolved = version or config["version"]
128
+ if ".dev" in resolved or "-dev" in resolved:
129
+ raise ValueError("publish 命令只支持发布 release 版本,不能使用 dev 版本")
130
+ return resolved
131
+
132
+
133
+ def find_local_release_product(project_root: Path, version: str) -> LocalReleaseProduct:
134
+ """定位 build 命令已生成的 release 分发产物。"""
135
+ config = load_config(project_root)
136
+ version_dir = project_root / config["build_root"] / "dist" / version
137
+ dist_dir = version_dir / "dist"
138
+ if not dist_dir.is_dir() or not any(dist_dir.glob("*.whl")):
139
+ raise FileNotFoundError(
140
+ f"未找到 release 分发文件:{dist_dir},请先执行 `shipcli build --release`"
141
+ )
142
+ return LocalReleaseProduct(
143
+ app_name=config["app_name"],
144
+ version=version,
145
+ version_dir=version_dir,
146
+ dist_dir=dist_dir,
147
+ )
148
+
149
+
150
+ def prepare_github_assets(project_root: Path, version: str) -> tuple[Path, ...]:
151
+ """准备 GitHub Release 需要上传的资产与校验文件(wheel/sdist + sha256)。"""
152
+ product = find_local_release_product(project_root, version)
153
+ assets: list[Path] = []
154
+ for pattern in ("*.whl", "*.tar.gz"):
155
+ for path in sorted(product.dist_dir.glob(pattern)):
156
+ assets.append(path)
157
+ assets.append(write_sha256_file(path))
158
+ if not assets:
159
+ raise FileNotFoundError(
160
+ f"未找到可上传的分发文件:{product.dist_dir},请先执行 `shipcli build --release`"
161
+ )
162
+ return tuple(assets)
163
+
164
+
165
+ def write_sha256_file(asset_path: Path) -> Path:
166
+ """为发布资产生成 SHA256 校验文件。"""
167
+ digest = hashlib.sha256(asset_path.read_bytes()).hexdigest()
168
+ checksum_path = asset_path.with_name(asset_path.name + ".sha256")
169
+ checksum_path.write_text(f"{digest} {asset_path.name}\n", encoding="utf-8")
170
+ return checksum_path
171
+
172
+
173
+ def collect_pypi_distributions(
174
+ project_root: Path,
175
+ version: str,
176
+ dist_dir: Path | None = None,
177
+ ) -> tuple[Path, ...]:
178
+ """收集与目标版本匹配的 Python 分发文件。"""
179
+ if dist_dir is not None:
180
+ resolved_dist_dir = dist_dir.resolve()
181
+ else:
182
+ # 默认从构建产出的版本目录取,对齐 `shipcli build`。
183
+ config = load_config(project_root)
184
+ resolved_dist_dir = (project_root / config["build_root"] / "dist" / version / "dist").resolve()
185
+ if not resolved_dist_dir.is_dir():
186
+ raise FileNotFoundError(
187
+ f"未找到分发目录:{resolved_dist_dir},请先执行 `shipcli build --release`"
188
+ )
189
+
190
+ candidates = sorted(
191
+ [
192
+ *resolved_dist_dir.glob("*.whl"),
193
+ *resolved_dist_dir.glob("*.tar.gz"),
194
+ ]
195
+ )
196
+ matched = tuple(path for path in candidates if version in path.name)
197
+ if not matched:
198
+ raise FileNotFoundError(
199
+ f"未找到版本 {version} 的分发文件:{resolved_dist_dir}"
200
+ )
201
+ return matched
202
+
203
+
204
+ def publish_to_pypi(
205
+ project_root: Path,
206
+ version: str,
207
+ repository_url: str | None = None,
208
+ dist_dir: Path | None = None,
209
+ skip_existing: bool = False,
210
+ ) -> PyPIPublishResult:
211
+ """把已存在的 Python 分发文件上传到 PyPI 仓库。"""
212
+ distributions = collect_pypi_distributions(project_root, version, dist_dir=dist_dir)
213
+ if importlib.util.find_spec("twine") is None:
214
+ raise RuntimeError("未安装 twine,请先执行 `python3 -m pip install twine`")
215
+
216
+ env = os.environ.copy()
217
+ token = env.get("SHIPCLI_PYPI_TOKEN")
218
+ if token:
219
+ env["TWINE_USERNAME"] = "__token__"
220
+ env["TWINE_PASSWORD"] = token
221
+ elif not env.get("TWINE_PASSWORD"):
222
+ raise RuntimeError("发布到 PyPI 需要设置 `SHIPCLI_PYPI_TOKEN` 或 `TWINE_PASSWORD`")
223
+
224
+ command = [sys.executable, "-m", "twine", "upload", "--non-interactive"]
225
+ if repository_url:
226
+ command.extend(["--repository-url", repository_url])
227
+ if skip_existing:
228
+ command.append("--skip-existing")
229
+ command.extend(str(path) for path in distributions)
230
+ subprocess.run(command, check=True, cwd=project_root, env=env)
231
+ return PyPIPublishResult(version=version, distribution_paths=distributions)
232
+
233
+
234
+ def publish_to_github_release(
235
+ project_root: Path,
236
+ version: str,
237
+ repo: str | None = None,
238
+ tag: str | None = None,
239
+ title: str | None = None,
240
+ notes: str | None = None,
241
+ draft: bool = False,
242
+ prerelease: bool = False,
243
+ ) -> GitHubPublishResult:
244
+ """把已构建好的 release 产物发布到 GitHub Release。"""
245
+ product = find_local_release_product(project_root, version)
246
+ token = os.environ.get("SHIPCLI_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
247
+ if not token:
248
+ raise RuntimeError("发布到 GitHub 需要设置 `SHIPCLI_GITHUB_TOKEN` 或 `GITHUB_TOKEN`")
249
+
250
+ resolved_repo = repo or os.environ.get("SHIPCLI_GITHUB_REPO") or os.environ.get("GITHUB_REPOSITORY")
251
+ if not resolved_repo:
252
+ raise RuntimeError(
253
+ "发布到 GitHub 需要提供 `--github-repo`,或设置 `SHIPCLI_GITHUB_REPO`/`GITHUB_REPOSITORY`"
254
+ )
255
+
256
+ resolved_tag = tag or f"v{version}"
257
+ resolved_title = title or f"{product.app_name} {version}"
258
+ release = get_or_create_github_release(
259
+ repo=resolved_repo,
260
+ token=token,
261
+ tag=resolved_tag,
262
+ title=resolved_title,
263
+ notes=notes or "",
264
+ draft=draft,
265
+ prerelease=prerelease,
266
+ )
267
+ upload_url = str(release["upload_url"]).split("{", 1)[0]
268
+ existing_assets = {
269
+ str(asset["name"]): int(asset["id"])
270
+ for asset in release.get("assets", [])
271
+ if "name" in asset and "id" in asset
272
+ }
273
+
274
+ asset_paths = prepare_github_assets(project_root, version)
275
+ for asset_path in asset_paths:
276
+ existing_id = existing_assets.get(asset_path.name)
277
+ if existing_id is not None:
278
+ delete_github_asset(resolved_repo, token, existing_id)
279
+ upload_github_asset(upload_url, token, asset_path)
280
+
281
+ return GitHubPublishResult(
282
+ version=version,
283
+ release_url=str(release["html_url"]),
284
+ asset_paths=asset_paths,
285
+ )
286
+
287
+
288
+ def get_or_create_github_release(
289
+ repo: str,
290
+ token: str,
291
+ tag: str,
292
+ title: str,
293
+ notes: str,
294
+ draft: bool,
295
+ prerelease: bool,
296
+ ) -> dict[str, Any]:
297
+ """读取指定 tag 的 Release,不存在时自动创建。"""
298
+ api_base = f"https://api.github.com/repos/{repo}"
299
+ existing = find_github_release_by_tag(repo, token, tag)
300
+ if existing is not None:
301
+ return existing
302
+
303
+ payload = {
304
+ "tag_name": tag,
305
+ "name": title,
306
+ "body": notes,
307
+ "draft": draft,
308
+ "prerelease": prerelease,
309
+ "generate_release_notes": False,
310
+ }
311
+ return github_json_request(
312
+ f"{api_base}/releases",
313
+ token,
314
+ method="POST",
315
+ payload=payload,
316
+ expected_statuses=(201,),
317
+ )
318
+
319
+
320
+ def find_github_release_by_tag(repo: str, token: str, tag: str) -> dict[str, Any] | None:
321
+ """按 tag 查找 GitHub Release,不存在时返回 `None`。"""
322
+ url = f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe='')}"
323
+ headers = {
324
+ "Accept": "application/vnd.github+json",
325
+ "Authorization": f"Bearer {token}",
326
+ "X-GitHub-Api-Version": "2022-11-28",
327
+ }
328
+ request = urllib.request.Request(url, headers=headers, method="GET")
329
+ try:
330
+ with urllib.request.urlopen(request) as response:
331
+ raw = response.read()
332
+ except urllib.error.HTTPError as exc:
333
+ if exc.code == 404:
334
+ return None
335
+ message = _read_github_error(exc)
336
+ raise RuntimeError(message) from exc
337
+
338
+ if not raw:
339
+ return {}
340
+ return json.loads(raw.decode("utf-8"))
341
+
342
+
343
+ def delete_github_asset(repo: str, token: str, asset_id: int) -> None:
344
+ """删除 GitHub Release 中同名的旧资产。"""
345
+ github_json_request(
346
+ f"https://api.github.com/repos/{repo}/releases/assets/{asset_id}",
347
+ token,
348
+ method="DELETE",
349
+ expected_statuses=(204,),
350
+ )
351
+
352
+
353
+ def upload_github_asset(upload_url: str, token: str, asset_path: Path) -> None:
354
+ """上传单个资产到 GitHub Release。"""
355
+ url = f"{upload_url}?{urllib.parse.urlencode({'name': asset_path.name})}"
356
+ content_type = mimetypes.guess_type(asset_path.name)[0] or "application/octet-stream"
357
+ github_binary_request(
358
+ url=url,
359
+ token=token,
360
+ data=asset_path.read_bytes(),
361
+ content_type=content_type,
362
+ expected_statuses=(201,),
363
+ )
364
+
365
+
366
+ def github_json_request(
367
+ url: str,
368
+ token: str,
369
+ method: str = "GET",
370
+ payload: dict[str, Any] | None = None,
371
+ expected_statuses: tuple[int, ...] = (200, 201),
372
+ ) -> dict[str, Any]:
373
+ """向 GitHub API 发送 JSON 请求。"""
374
+ headers = {
375
+ "Accept": "application/vnd.github+json",
376
+ "Authorization": f"Bearer {token}",
377
+ "X-GitHub-Api-Version": "2022-11-28",
378
+ }
379
+ data = None
380
+ if payload is not None:
381
+ headers["Content-Type"] = "application/json"
382
+ data = json.dumps(payload).encode("utf-8")
383
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
384
+ return _open_github_request(request, expected_statuses)
385
+
386
+
387
+ def github_binary_request(
388
+ url: str,
389
+ token: str,
390
+ data: bytes,
391
+ content_type: str,
392
+ expected_statuses: tuple[int, ...] = (201,),
393
+ ) -> dict[str, Any]:
394
+ """向 GitHub 上传二进制资产。"""
395
+ headers = {
396
+ "Accept": "application/vnd.github+json",
397
+ "Authorization": f"Bearer {token}",
398
+ "Content-Type": content_type,
399
+ "X-GitHub-Api-Version": "2022-11-28",
400
+ }
401
+ request = urllib.request.Request(url, data=data, headers=headers, method="POST")
402
+ return _open_github_request(request, expected_statuses)
403
+
404
+
405
+ def _open_github_request(
406
+ request: urllib.request.Request,
407
+ expected_statuses: tuple[int, ...],
408
+ ) -> dict[str, Any]:
409
+ """统一处理 GitHub API 返回结果与错误信息。"""
410
+ try:
411
+ with urllib.request.urlopen(request) as response:
412
+ if response.status not in expected_statuses:
413
+ raise RuntimeError(f"GitHub API 返回了未预期状态码:{response.status}")
414
+ raw = response.read()
415
+ except urllib.error.HTTPError as exc:
416
+ message = _read_github_error(exc)
417
+ raise RuntimeError(message) from exc
418
+
419
+ if not raw:
420
+ return {}
421
+ return json.loads(raw.decode("utf-8"))
422
+
423
+
424
+ def _read_github_error(exc: urllib.error.HTTPError) -> str:
425
+ """提取 GitHub API 错误消息,避免泄露请求凭证。"""
426
+ payload = exc.read().decode("utf-8", errors="replace")
427
+ try:
428
+ data = json.loads(payload)
429
+ except json.JSONDecodeError:
430
+ return f"GitHub API 请求失败:HTTP {exc.code}"
431
+ message = data.get("message") or f"HTTP {exc.code}"
432
+ return f"GitHub API 请求失败:{message}"