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/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """shipcli 包入口。"""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.0.1"
shipcli/__main__.py ADDED
@@ -0,0 +1,11 @@
1
+ """shipcli 模块入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from shipcli.cli import main
8
+
9
+
10
+ if __name__ == "__main__":
11
+ sys.exit(main())
shipcli/builder.py ADDED
@@ -0,0 +1,220 @@
1
+ """shipcli 纯 Python 构建流程。
2
+
3
+ 方向 A:build 产出标准的 wheel/sdist(去掉 PyInstaller 二进制)。
4
+ - debug 构建(`shipcli build`)产 dev 版 wheel,版本号 `<base>.devN`。
5
+ - release 构建(`shipcli build --release`)产 release 版 wheel/sdist。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import json
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from shipcli.config import BuildConfig, config_path, load_config
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BuildPlan:
23
+ """描述一次构建的关键路径与版本信息。"""
24
+
25
+ app_name: str
26
+ build_root: Path
27
+ dist_root: Path
28
+ product_dir: Path
29
+ dist_dir: Path
30
+ product_version: str
31
+ base_version: str
32
+ build_number: int
33
+ release: bool
34
+
35
+
36
+ def bump_version(version: str, part: str) -> str:
37
+ """把语义版本号的指定位 +1,低位归零。"""
38
+ if part not in {"major", "minor", "patch"}:
39
+ raise ValueError("版本位仅支持 major / minor / patch")
40
+ segments = version.split(".")
41
+ if len(segments) != 3 or not all(seg.isdigit() for seg in segments):
42
+ raise ValueError(f"版本号不是标准的三段语义版本:{version}")
43
+ major, minor, patch = (int(seg) for seg in segments)
44
+ if part == "major":
45
+ major, minor, patch = major + 1, 0, 0
46
+ elif part == "minor":
47
+ minor, patch = minor + 1, 0
48
+ else:
49
+ patch += 1
50
+ return f"{major}.{minor}.{patch}"
51
+
52
+
53
+ def resolve_build_plan(
54
+ project_root: Path,
55
+ config: BuildConfig,
56
+ release: bool = False,
57
+ increase: str | None = None,
58
+ ) -> BuildPlan:
59
+ """根据配置和参数生成本次构建计划。"""
60
+ if increase is not None:
61
+ base_version = bump_version(config["version"], increase)
62
+ else:
63
+ base_version = config["version"]
64
+
65
+ build_root = project_root / config["build_root"]
66
+ dist_root = build_root / "dist"
67
+ if release:
68
+ build_number = config["build"]
69
+ product_version = base_version
70
+ else:
71
+ build_number = config["build"] + 1
72
+ # PEP 440 规范的 dev 版本号:`<base>.devN`(可被 pip/PyPI 接受)。
73
+ product_version = f"{base_version}.dev{build_number}"
74
+
75
+ product_dir = dist_root / product_version
76
+ dist_dir = product_dir / "dist"
77
+ return BuildPlan(
78
+ app_name=config["app_name"],
79
+ build_root=build_root,
80
+ dist_root=dist_root,
81
+ product_dir=product_dir,
82
+ dist_dir=dist_dir,
83
+ product_version=product_version,
84
+ base_version=base_version,
85
+ build_number=build_number,
86
+ release=release,
87
+ )
88
+
89
+
90
+ def run_build(
91
+ project_root: Path,
92
+ release: bool = False,
93
+ increase: str | None = None,
94
+ ) -> BuildPlan:
95
+ """生成本次构建的 wheel/sdist,并在 debug 构建成功后回写 build。"""
96
+ config = load_config(project_root)
97
+ if not (project_root / "pyproject.toml").is_file():
98
+ raise FileNotFoundError("未找到 pyproject.toml,无法构建分发文件")
99
+ if shutil_is_missing_python():
100
+ raise RuntimeError("未找到可用的 Python 解释器")
101
+
102
+ # --increase 先把 bump 后的版本与重置的 build=0 回写配置,再据此生成计划,
103
+ # 保证后续 dev 号、产物目录都基于新版本与新 build 号。
104
+ if increase is not None:
105
+ config["version"] = bump_version(config["version"], increase)
106
+ config["build"] = 0
107
+ _write_version_and_build(project_root, config["version"], config["build"])
108
+
109
+ plan = resolve_build_plan(project_root, config, release=release, increase=None)
110
+
111
+ plan.build_root.mkdir(parents=True, exist_ok=True)
112
+ if plan.product_dir.exists():
113
+ import shutil
114
+
115
+ shutil.rmtree(plan.product_dir)
116
+ plan.product_dir.mkdir(parents=True, exist_ok=True)
117
+
118
+ # release 构建需把版本同步到 pyproject.toml,再产 wheel/sdist;
119
+ # dev 构建也要把 dev 版号同步进 pyproject.toml,否则 wheel 版本号不对。
120
+ target_version = plan.base_version if plan.release else plan.product_version
121
+ _sync_pyproject_version(project_root, target_version)
122
+ _write_package_version(project_root, config, plan.product_version)
123
+ _build_distributions(project_root, plan)
124
+
125
+ if not plan.release:
126
+ _write_build_number(project_root, plan.build_number)
127
+ else:
128
+ # release 构建会改写版本相关文件,git 仓库下自动提交,
129
+ # 保证后续 publish 打的 tag 落在「版本号已更新」的完整 commit 上。
130
+ _maybe_commit_release(project_root, plan.base_version)
131
+ return plan
132
+
133
+
134
+ def _maybe_commit_release(project_root: Path, version: str) -> None:
135
+ """release 构建后,若为 git 仓库且有改动,自动提交版本发布点。"""
136
+ from shipcli.publisher import git_commit_all, git_working_tree_clean, is_git_repo
137
+
138
+ if not is_git_repo(project_root):
139
+ return
140
+ if git_working_tree_clean(project_root):
141
+ return
142
+ git_commit_all(project_root, f"release v{version}")
143
+
144
+
145
+ def shutil_is_missing_python() -> bool:
146
+ """检测当前解释器是否可用(保留可测试的薄封装)。"""
147
+ import shutil
148
+
149
+ return shutil.which(sys.executable) is None
150
+
151
+
152
+ def _build_distributions(project_root: Path, plan: BuildPlan) -> None:
153
+ """调用 `python -m build` 生成 wheel/sdist 到 plan.dist_dir。"""
154
+ if importlib.util.find_spec("build") is None:
155
+ raise RuntimeError("未安装 build,请先执行 `python3 -m pip install build`")
156
+
157
+ plan.dist_dir.mkdir(parents=True, exist_ok=True)
158
+ command = [
159
+ sys.executable,
160
+ "-m",
161
+ "build",
162
+ "--outdir",
163
+ str(plan.dist_dir),
164
+ str(project_root),
165
+ ]
166
+ subprocess.run(command, check=True, cwd=project_root)
167
+ if not any(plan.dist_dir.glob("*.whl")):
168
+ raise RuntimeError(f"分发文件构建失败:{plan.dist_dir}")
169
+
170
+
171
+ def _package_init_path(project_root: Path, config: BuildConfig) -> Path:
172
+ """根据构建入口推导包根目录下的 __init__.py 路径。"""
173
+ entry = config["entry"]
174
+ package = entry.split("/__main__.py", 1)[0] if entry.endswith("/__main__.py") else entry.rsplit("/", 1)[0]
175
+ return project_root / package / "__init__.py"
176
+
177
+
178
+ def _write_package_version(project_root: Path, config: BuildConfig, version: str) -> None:
179
+ """把本次构建版本写入包 __init__.py 的 __version__,供打包后运行时读取。"""
180
+ path = _package_init_path(project_root, config)
181
+ if not path.is_file():
182
+ raise FileNotFoundError(f"包入口文件不存在:{path}")
183
+ text = path.read_text(encoding="utf-8")
184
+ pattern = re.compile(r'(?m)^(__version__\s*=\s*")([^"]*)("\s*)$')
185
+ if not pattern.search(text):
186
+ raise RuntimeError(f"{path} 缺少 __version__ 定义,无法写入版本号")
187
+ synced = pattern.sub(lambda m: f'{m.group(1)}{version}{m.group(3)}', text)
188
+ if synced != text:
189
+ path.write_text(synced, encoding="utf-8")
190
+
191
+
192
+ def _sync_pyproject_version(project_root: Path, version: str) -> None:
193
+ """把目标版本同步到 pyproject.toml,保证 wheel 版本号正确。"""
194
+ path = project_root / "pyproject.toml"
195
+ if not path.is_file():
196
+ return
197
+ text = path.read_text(encoding="utf-8")
198
+ pattern = re.compile(r'(?m)^(\s*version\s*=\s*")([^"]*)("\s*)$')
199
+ if not pattern.search(text):
200
+ return # 静态版本号缺失时跳过(可能用了动态版本,不强行改写)
201
+ synced = pattern.sub(lambda m: f'{m.group(1)}{version}{m.group(3)}', text)
202
+ if synced != text:
203
+ path.write_text(synced, encoding="utf-8")
204
+
205
+
206
+ def _write_version_and_build(project_root: Path, version: str, build: int) -> None:
207
+ """更新 build.config.json 中的 version 与 build 字段。"""
208
+ path = config_path(project_root)
209
+ payload = json.loads(path.read_text(encoding="utf-8"))
210
+ payload["version"] = version
211
+ payload["build"] = build
212
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
213
+
214
+
215
+ def _write_build_number(project_root: Path, build_number: int) -> None:
216
+ """只更新 build.config.json 中的 build 字段。"""
217
+ path = config_path(project_root)
218
+ payload = json.loads(path.read_text(encoding="utf-8"))
219
+ payload["build"] = build_number
220
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
shipcli/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ """shipcli argparse 主入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Optional
8
+
9
+ from shipcli.commands import (
10
+ run_build_command,
11
+ run_init,
12
+ run_install,
13
+ run_publish,
14
+ run_uninstall,
15
+ run_upgrade,
16
+ )
17
+ from shipcli.console import Console, TerminalConsole
18
+ from shipcli.version import display_version_with_mode, install_mode
19
+
20
+
21
+ class HelpFormatter(argparse.RawTextHelpFormatter):
22
+ """保持帮助文案换行,并稳定子命令列表排版。"""
23
+
24
+ def __init__(self, prog: str) -> None:
25
+ """固定帮助列位置,避免短说明被意外换到下一行。"""
26
+ super().__init__(prog, max_help_position=28)
27
+
28
+ def _format_action(self, action: argparse.Action) -> str:
29
+ """对子命令列表使用稳定的一行式排版。"""
30
+ if isinstance(action, argparse._SubParsersAction):
31
+ parts = [f"{self._current_indent * ' '}{self._format_action_invocation(action)}\n"]
32
+ subactions = action._get_subactions()
33
+ if not subactions:
34
+ return "".join(parts)
35
+
36
+ self._indent()
37
+ width = max(len(self._format_action_invocation(subaction)) for subaction in subactions)
38
+ for subaction in subactions:
39
+ command = self._format_action_invocation(subaction)
40
+ help_text = self._expand_help(subaction) if subaction.help else ""
41
+ parts.append(f"{self._current_indent * ' '}{command.ljust(width)} {help_text}\n")
42
+ self._dedent()
43
+ return "".join(parts)
44
+ return super()._format_action(action)
45
+
46
+
47
+ def build_parser() -> argparse.ArgumentParser:
48
+ """构造顶层参数解析器。"""
49
+ parser = argparse.ArgumentParser(
50
+ prog="shipcli",
51
+ description=(
52
+ f"纯 Python CLI 脚手架工具。\n"
53
+ f"安装形态:{install_mode()}\n\n"
54
+ ),
55
+ epilog=(
56
+ "示例:\n"
57
+ " shipcli init demo-cli\n"
58
+ " cd demo-cli && shipcli build\n"
59
+ " cd demo-cli && shipcli build --release\n"
60
+ " cd demo-cli && shipcli publish --github\n"
61
+ " cd demo-cli && shipcli install\n"
62
+ " shipcli build --project ./demo-cli\n"
63
+ " shipcli upgrade # 升级 shipcli 自身\n"
64
+ " shipcli uninstall # 卸载 shipcli 自身\n"
65
+ " demo-cli upgrade # 升级已安装的 demo-cli\n"
66
+ " demo-cli uninstall # 卸载已安装的 demo-cli"
67
+ ),
68
+ formatter_class=HelpFormatter,
69
+ )
70
+ parser.add_argument("--version", action="store_true", help="显示版本号")
71
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
72
+
73
+ init = sub.add_parser(
74
+ "init",
75
+ help="初始化一个新的 CLI 项目",
76
+ description="生成新的 Python CLI 项目目录、配置文件和基础命令。操作目标项目。",
77
+ formatter_class=HelpFormatter,
78
+ )
79
+ init.add_argument("path", help="目标目录路径")
80
+ init.add_argument("--app-name", default=None, help="命令名,如 demo")
81
+ init.add_argument("--package", default=None, help="Python 包名,如 demo_cli")
82
+ init.add_argument("--force", action="store_true", help="目标目录非空时也继续写入")
83
+
84
+ build = sub.add_parser(
85
+ "build",
86
+ help="构建目标项目",
87
+ description=(
88
+ "生成本项目的 wheel/sdist 分发文件;操作当前目录或 --project 指定的项目。\n\n"
89
+ "默认 dev 构建(版本号 <base>.devN);--release 产出 release 版本。"
90
+ ),
91
+ formatter_class=HelpFormatter,
92
+ )
93
+ build.add_argument("--project", default=".", help="项目根目录;默认当前目录")
94
+ build.add_argument("--release", action="store_true", help="执行 release 构建")
95
+ build.add_argument(
96
+ "--increase",
97
+ choices=["major", "minor", "patch"],
98
+ default=None,
99
+ help="构建前把版本号指定位 +1(低位归零)并重置 build;可与 --release 同用",
100
+ )
101
+
102
+ publish = sub.add_parser(
103
+ "publish",
104
+ help="发布目标项目的 release 版本",
105
+ description="发布目标项目 build 已生成的 release 版本;不会在该命令中触发构建。",
106
+ formatter_class=HelpFormatter,
107
+ )
108
+ publish.add_argument("--project", default=".", help="项目根目录;默认当前目录")
109
+ publish.add_argument("--version", default=None, help="要发布的 release 版本;默认取配置中的 version")
110
+ publish.add_argument("--github", action="store_true", help="发布到 GitHub Release")
111
+ publish.add_argument("--pypi", action="store_true", help="发布到 PyPI 仓库")
112
+ publish.add_argument("--github-repo", default=None, help="GitHub 仓库,格式 owner/repo")
113
+ publish.add_argument("--tag", default=None, help="GitHub tag;默认 v<version>")
114
+ publish.add_argument("--title", default=None, help="GitHub Release 标题")
115
+ publish.add_argument("--notes-file", default=None, help="GitHub Release 说明文件路径")
116
+ publish.add_argument("--draft", action="store_true", help="创建草稿 GitHub Release")
117
+ publish.add_argument("--prerelease", action="store_true", help="标记为 GitHub 预发布")
118
+ publish.add_argument("--repository-url", default=None, help="PyPI 仓库地址;默认使用 twine 默认配置")
119
+ publish.add_argument("--dist-dir", default=None, help="PyPI 分发目录;默认当前项目下的 dist")
120
+ publish.add_argument("--skip-existing", action="store_true", help="PyPI 上传时跳过已存在文件")
121
+ publish.add_argument("--no-tag", action="store_true", help="发布成功后不打 git tag")
122
+ publish.add_argument("--no-push-tag", action="store_true", help="只创建本地 git tag,不推送到远程")
123
+
124
+ install = sub.add_parser(
125
+ "install",
126
+ help="安装目标项目",
127
+ description="安装目标项目:默认 editable 安装源码;--version 安装指定 build 产物。",
128
+ formatter_class=HelpFormatter,
129
+ )
130
+ install.add_argument("--project", default=".", help="项目根目录;默认当前目录")
131
+ install.add_argument("--version", default=None, help="安装指定版本 build 产物;默认 editable 安装")
132
+
133
+ upgrade = sub.add_parser(
134
+ "upgrade",
135
+ help="升级 shipcli 自身",
136
+ description=(
137
+ "升级 shipcli 自身:\n"
138
+ " 默认从 PyPI 拉最新发布版(--version 指定版本)\n"
139
+ " --local <path> 从本地项目 build 产物升级(--version 指定版本,缺省取最新)\n\n"
140
+ "此命令只管 shipcli 自己。项目 CLI(如 demo-cli)用其自带命令:demo-cli upgrade。"
141
+ ),
142
+ formatter_class=HelpFormatter,
143
+ )
144
+ upgrade.add_argument("--version", default=None, help="升级到指定版本;PyPI 为发布版,--local 为 build 产物版本")
145
+ upgrade.add_argument("--local", default=None, help="从本地项目路径的 build 产物升级")
146
+
147
+ uninstall = sub.add_parser(
148
+ "uninstall",
149
+ help="卸载 shipcli 自身",
150
+ description=(
151
+ "卸载 shipcli 自身:pip 卸载发布版包,并清理本地安装目录与符号链接。\n\n"
152
+ "此命令只管 shipcli 自己。项目 CLI(如 demo-cli)用其自带命令:demo-cli uninstall。"
153
+ ),
154
+ formatter_class=HelpFormatter,
155
+ )
156
+ return parser
157
+
158
+
159
+ def main(argv: Optional[list[str]] = None, console: Optional[Console] = None) -> int:
160
+ """解析命令并分发到子命令。"""
161
+ console = console or TerminalConsole()
162
+ parser = build_parser()
163
+ args = parser.parse_args(argv)
164
+
165
+ if args.version and not args.command:
166
+ console.print(f"shipcli {display_version_with_mode()}")
167
+ return 0
168
+ if args.command == "init":
169
+ return run_init(args, console)
170
+ if args.command == "build":
171
+ return run_build_command(args, console)
172
+ if args.command == "publish":
173
+ return run_publish(args, console)
174
+ if args.command == "install":
175
+ return run_install(args, console)
176
+ if args.command == "upgrade":
177
+ return run_upgrade(args, console)
178
+ if args.command == "uninstall":
179
+ return run_uninstall(args, console)
180
+ parser.print_help()
181
+ return 1
182
+
183
+
184
+ if __name__ == "__main__":
185
+ sys.exit(main())
@@ -0,0 +1,17 @@
1
+ """shipcli 子命令导出。"""
2
+
3
+ from shipcli.commands.build import run_build_command
4
+ from shipcli.commands.init import run_init
5
+ from shipcli.commands.install import run_install
6
+ from shipcli.commands.publish import run_publish
7
+ from shipcli.commands.uninstall import run_uninstall
8
+ from shipcli.commands.upgrade import run_upgrade
9
+
10
+ __all__ = [
11
+ "run_init",
12
+ "run_build_command",
13
+ "run_install",
14
+ "run_publish",
15
+ "run_upgrade",
16
+ "run_uninstall",
17
+ ]
@@ -0,0 +1,30 @@
1
+ """shipcli build 子命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from shipcli.builder import run_build
9
+ from shipcli.console import Console
10
+
11
+
12
+ def run_build_command(args: Any, console: Console) -> int:
13
+ """执行当前项目或指定项目的构建,产出 wheel/sdist。"""
14
+ project_root = Path(args.project).resolve()
15
+ try:
16
+ plan = run_build(
17
+ project_root,
18
+ release=args.release,
19
+ increase=args.increase,
20
+ )
21
+ except Exception as exc: # noqa: BLE001 - CLI 统一兜底输出错误信息
22
+ console.print(f"错误: {exc}")
23
+ return 1
24
+ kind = "release" if plan.release else "dev"
25
+ console.print(f"构建完成({kind}):{plan.product_version}")
26
+ console.print(f"分发目录:{plan.dist_dir}")
27
+ # 打印现成的安装/升级命令,方便复制。
28
+ console.print(f" pip install {plan.dist_dir}/*.whl")
29
+ console.print(f" shipcli upgrade --local {project_root} --version {plan.product_version}")
30
+ return 0
@@ -0,0 +1,30 @@
1
+ """shipcli init 子命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from shipcli.console import Console
9
+ from shipcli.scaffold import create_project, default_package_name
10
+
11
+
12
+ def run_init(args: Any, console: Console) -> int:
13
+ """初始化一个新的 CLI 项目目录。"""
14
+ target_dir = Path(args.path).resolve()
15
+ app_name = args.app_name or target_dir.name
16
+ package_name = args.package or default_package_name(app_name)
17
+ try:
18
+ create_project(
19
+ target_dir,
20
+ app_name=app_name,
21
+ package_name=package_name,
22
+ force=args.force,
23
+ )
24
+ except FileExistsError as exc:
25
+ console.print(f"错误: {exc}")
26
+ return 1
27
+ console.print(f"已初始化项目:{target_dir}")
28
+ console.print(f"应用名:{app_name}")
29
+ console.print(f"包名:{package_name}")
30
+ return 0
@@ -0,0 +1,22 @@
1
+ """shipcli install 子命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from shipcli.console import Console
9
+ from shipcli.installer import install_project
10
+
11
+
12
+ def run_install(args: Any, console: Console) -> int:
13
+ """安装源码入口或指定版本产物。"""
14
+ project_root = Path(args.project).resolve()
15
+ try:
16
+ plan = install_project(project_root, version=args.version)
17
+ except Exception as exc: # noqa: BLE001 - CLI 统一兜底输出错误信息
18
+ console.print(f"错误: {exc}")
19
+ return 1
20
+ console.print(f"已安装:{plan.exec_path}")
21
+ console.print(f"符号链接:{plan.link_path}")
22
+ return 0
@@ -0,0 +1,83 @@
1
+ """shipcli publish 子命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from shipcli.console import Console
9
+ from shipcli.publisher import (
10
+ create_git_tag,
11
+ git_working_tree_clean,
12
+ is_git_repo,
13
+ publish_to_github_release,
14
+ publish_to_pypi,
15
+ resolve_release_version,
16
+ )
17
+
18
+
19
+ def run_publish(args: Any, console: Console) -> int:
20
+ """发布当前项目或指定项目的既有 release 产物,并按需打 git tag。"""
21
+ if not args.github and not args.pypi:
22
+ console.print("错误: 请至少指定一个发布目标:`--github` 或 `--pypi`")
23
+ return 1
24
+
25
+ project_root = Path(args.project).resolve()
26
+ try:
27
+ version = resolve_release_version(project_root, args.version)
28
+ console.print(f"准备发布版本:{version}")
29
+
30
+ # 打 tag 前先检查工作区:release 构建应在 build 阶段已自动提交版本号,
31
+ # 此处若有改动说明源码尚未提交,打 tag 会落在不完整的 commit 上,必须拦下。
32
+ if not args.no_tag and is_git_repo(project_root) and not git_working_tree_clean(project_root):
33
+ raise RuntimeError(
34
+ "工作区有未提交改动,打 tag 会落在不完整的 commit 上;"
35
+ "请先 commit 改动(或重新 `shipcli build --release` 自动提交版本号)再发布,"
36
+ "或加 --no-tag 跳过打 tag。"
37
+ )
38
+
39
+ if args.github:
40
+ github_result = publish_to_github_release(
41
+ project_root=project_root,
42
+ version=version,
43
+ repo=args.github_repo,
44
+ tag=args.tag,
45
+ title=args.title,
46
+ notes=_load_release_notes(args.notes_file),
47
+ draft=args.draft,
48
+ prerelease=args.prerelease,
49
+ )
50
+ console.print(f"GitHub Release 已发布:{github_result.release_url}")
51
+ for asset_path in github_result.asset_paths:
52
+ console.print(f"GitHub 资产:{asset_path}")
53
+ if args.pypi:
54
+ pypi_result = publish_to_pypi(
55
+ project_root=project_root,
56
+ version=version,
57
+ repository_url=args.repository_url,
58
+ dist_dir=Path(args.dist_dir).resolve() if args.dist_dir else None,
59
+ skip_existing=args.skip_existing,
60
+ )
61
+ console.print("PyPI 分发文件已上传:")
62
+ for distribution_path in pypi_result.distribution_paths:
63
+ console.print(f" {distribution_path}")
64
+
65
+ # 发布都成功后,若项目是 git 仓库则打 v<version> tag(已存在则跳过)。
66
+ if not args.no_tag and is_git_repo(project_root):
67
+ tag = args.tag or f"v{version}"
68
+ created = create_git_tag(project_root, tag, push=not args.no_push_tag)
69
+ if created:
70
+ console.print(f"已创建 git tag:{tag}")
71
+ else:
72
+ console.print(f"git tag 已存在,跳过:{tag}")
73
+ except Exception as exc: # noqa: BLE001 - CLI 统一兜底输出错误信息
74
+ console.print(f"错误: {exc}")
75
+ return 1
76
+ return 0
77
+
78
+
79
+ def _load_release_notes(notes_file: str | None) -> str | None:
80
+ """读取 GitHub Release 说明文件。"""
81
+ if not notes_file:
82
+ return None
83
+ return Path(notes_file).read_text(encoding="utf-8")
@@ -0,0 +1,47 @@
1
+ """shipcli uninstall 子命令。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from shipcli.console import Console
12
+
13
+
14
+ PACKAGE_NAME = "shipcli"
15
+
16
+
17
+ def run_uninstall(args: Any, console: Console) -> int:
18
+ """卸载 shipcli 自身:pip 卸载包,并清理本地安装目录与符号链接。"""
19
+ try:
20
+ # pip 卸载发布版包(-y 跳过确认)。
21
+ subprocess.run(
22
+ [sys.executable, "-m", "pip", "uninstall", "-y", PACKAGE_NAME, "--disable-pip-version-check"],
23
+ check=False,
24
+ )
25
+ # 清理 ~/.shipcli-cli/ 与符号链接(与发布无关的本地安装痕迹)。
26
+ install_dir = Path.home() / f".{PACKAGE_NAME}-cli"
27
+ link_dirs = [
28
+ Path("/usr/local/bin"),
29
+ Path.home() / ".local" / "bin",
30
+ ]
31
+ removed_links = []
32
+ for link_dir in link_dirs:
33
+ link_path = link_dir / PACKAGE_NAME
34
+ if link_path.exists() or link_path.is_symlink():
35
+ link_path.unlink(missing_ok=True)
36
+ removed_links.append(link_path)
37
+ if install_dir.exists():
38
+ shutil.rmtree(install_dir)
39
+ except Exception as exc: # noqa: BLE001 - CLI 统一兜底输出错误信息
40
+ console.print(f"错误: {exc}")
41
+ return 1
42
+ console.print(f"已卸载 pip 包:{PACKAGE_NAME}")
43
+ console.print(f"已清理安装目录:{Path.home() / f'.{PACKAGE_NAME}-cli'}")
44
+ for link in removed_links:
45
+ console.print(f"已移除链接:{link}")
46
+ console.print("此命令只管 shipcli 自己;项目 CLI(如 demo-cli)用其自带命令卸载。")
47
+ return 0