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 +5 -0
- shipcli/__main__.py +11 -0
- shipcli/builder.py +220 -0
- shipcli/cli.py +185 -0
- shipcli/commands/__init__.py +17 -0
- shipcli/commands/build.py +30 -0
- shipcli/commands/init.py +30 -0
- shipcli/commands/install.py +22 -0
- shipcli/commands/publish.py +83 -0
- shipcli/commands/uninstall.py +47 -0
- shipcli/commands/upgrade.py +83 -0
- shipcli/config.py +35 -0
- shipcli/console.py +33 -0
- shipcli/installer.py +209 -0
- shipcli/publisher.py +432 -0
- shipcli/scaffold.py +411 -0
- shipcli/version.py +95 -0
- shipcli-0.0.1.dist-info/METADATA +182 -0
- shipcli-0.0.1.dist-info/RECORD +23 -0
- shipcli-0.0.1.dist-info/WHEEL +5 -0
- shipcli-0.0.1.dist-info/entry_points.txt +2 -0
- shipcli-0.0.1.dist-info/licenses/LICENSE +21 -0
- shipcli-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""shipcli upgrade 子命令。
|
|
2
|
+
|
|
3
|
+
升级 shipcli 自身:
|
|
4
|
+
- 默认从 PyPI 拉发布版(`pip install --upgrade shipcli`)。
|
|
5
|
+
- `--local <path>` 从本地项目的 build 产物装:`--version X` 指定版本,缺省取最新。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from shipcli.console import Console
|
|
16
|
+
from shipcli.installer import latest_product_version
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PACKAGE_NAME = "shipcli"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_upgrade(args: Any, console: Console) -> int:
|
|
23
|
+
"""升级 shipcli 自身:默认 PyPI,--local 走本地 build 产物。"""
|
|
24
|
+
try:
|
|
25
|
+
if args.local:
|
|
26
|
+
version = _upgrade_from_local(Path(args.local).resolve(), args.version)
|
|
27
|
+
else:
|
|
28
|
+
version = _upgrade_from_pypi(args.version)
|
|
29
|
+
except Exception as exc: # noqa: BLE001 - CLI 统一兜底输出错误信息
|
|
30
|
+
console.print(f"错误: {exc}")
|
|
31
|
+
return 1
|
|
32
|
+
console.print(f"升级完成:{version}")
|
|
33
|
+
console.print("此命令只管 shipcli 自己;项目 CLI(如 demo-cli)用其自带命令升级。")
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _upgrade_from_pypi(version: str | None) -> str:
|
|
38
|
+
"""从 PyPI 升级到最新或指定发布版本。"""
|
|
39
|
+
spec = [PACKAGE_NAME, "--upgrade"] if version is None else [f"{PACKAGE_NAME}=={version}"]
|
|
40
|
+
subprocess.run(
|
|
41
|
+
[sys.executable, "-m", "pip", "install", *spec, "--disable-pip-version-check"],
|
|
42
|
+
check=True,
|
|
43
|
+
)
|
|
44
|
+
return version or _installed_version() or "latest"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _upgrade_from_local(project_root: Path, version: str | None) -> str:
|
|
48
|
+
"""从本地项目的 build 产物升级;version 缺省时取最新版本。"""
|
|
49
|
+
resolved = version or latest_product_version(project_root)
|
|
50
|
+
wheel = _find_local_wheel(project_root, resolved)
|
|
51
|
+
subprocess.run(
|
|
52
|
+
[sys.executable, "-m", "pip", "install", "--force-reinstall", str(wheel), "--disable-pip-version-check"],
|
|
53
|
+
check=True,
|
|
54
|
+
)
|
|
55
|
+
return resolved
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _find_local_wheel(project_root: Path, version: str) -> Path:
|
|
59
|
+
"""定位本地某版本 build 产物中的 wheel 文件。"""
|
|
60
|
+
from shipcli.config import load_config
|
|
61
|
+
|
|
62
|
+
config = load_config(project_root)
|
|
63
|
+
dist_dir = project_root / config["build_root"] / "dist" / version / "dist"
|
|
64
|
+
wheels = sorted(dist_dir.glob("*.whl")) if dist_dir.is_dir() else []
|
|
65
|
+
if not wheels:
|
|
66
|
+
raise FileNotFoundError(
|
|
67
|
+
f"未找到本地 {version} 的 wheel:{dist_dir},请先执行 `shipcli build`"
|
|
68
|
+
)
|
|
69
|
+
return wheels[0]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _installed_version() -> str | None:
|
|
73
|
+
"""读取当前已安装的 shipcli 版本号;失败时返回 None。"""
|
|
74
|
+
result = subprocess.run(
|
|
75
|
+
[sys.executable, "-m", "pip", "show", PACKAGE_NAME, "--disable-pip-version-check"],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
text=True,
|
|
78
|
+
check=False,
|
|
79
|
+
)
|
|
80
|
+
for line in result.stdout.splitlines():
|
|
81
|
+
if line.startswith("Version:"):
|
|
82
|
+
return line.split(":", 1)[1].strip()
|
|
83
|
+
return None
|
shipcli/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""shipcli 构建配置读取。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BuildConfig(TypedDict):
|
|
11
|
+
"""`build.config.json` 的字段结构。"""
|
|
12
|
+
|
|
13
|
+
app_name: str
|
|
14
|
+
entry: str
|
|
15
|
+
build_root: str
|
|
16
|
+
version: str
|
|
17
|
+
build: int
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path(project_root: Path) -> Path:
|
|
21
|
+
"""返回项目根目录下的构建配置路径。"""
|
|
22
|
+
return project_root / "build.config.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_config(project_root: Path) -> BuildConfig:
|
|
26
|
+
"""读取项目构建配置。"""
|
|
27
|
+
path = config_path(project_root)
|
|
28
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
29
|
+
return BuildConfig(
|
|
30
|
+
app_name=data["app_name"],
|
|
31
|
+
entry=data["entry"],
|
|
32
|
+
build_root=data.get("build_root", ".build"),
|
|
33
|
+
version=data["version"],
|
|
34
|
+
build=int(data["build"]),
|
|
35
|
+
)
|
shipcli/console.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""shipcli 终端 I/O 抽象。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Console:
|
|
9
|
+
"""终端 I/O 基类。"""
|
|
10
|
+
|
|
11
|
+
def print(self, message: str = "") -> None: # noqa: A003
|
|
12
|
+
"""输出一行到终端。"""
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TerminalConsole(Console):
|
|
17
|
+
"""默认终端输出实现。"""
|
|
18
|
+
|
|
19
|
+
def print(self, message: str = "") -> None:
|
|
20
|
+
"""把文本打印到标准输出。"""
|
|
21
|
+
print(message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeConsole(Console):
|
|
25
|
+
"""测试桩,记录输出内容。"""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
"""初始化测试输出缓冲区。"""
|
|
29
|
+
self.stdout: List[str] = []
|
|
30
|
+
|
|
31
|
+
def print(self, message: str = "") -> None:
|
|
32
|
+
"""追加一条输出,供测试断言。"""
|
|
33
|
+
self.stdout.append(message)
|
shipcli/installer.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""shipcli 纯 Python 安装、升级与卸载流程。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from shipcli.config import load_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class InstallPlan:
|
|
17
|
+
"""描述一次安装的目标位置。"""
|
|
18
|
+
|
|
19
|
+
app_name: str
|
|
20
|
+
install_dir: Path
|
|
21
|
+
exec_path: Path
|
|
22
|
+
current_dir: Path
|
|
23
|
+
link_dir: Path
|
|
24
|
+
link_path: Path
|
|
25
|
+
build_dist_dir: Path
|
|
26
|
+
entry_path: Path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_install_metadata(install_dir: Path) -> dict[str, Any]:
|
|
30
|
+
"""读取安装元信息;不存在或解析失败时返回空字典。"""
|
|
31
|
+
path = install_dir / "VERSION.installed"
|
|
32
|
+
if not path.is_file():
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return {}
|
|
38
|
+
return data if isinstance(data, dict) else {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_install_plan(project_root: Path) -> InstallPlan:
|
|
42
|
+
"""根据项目配置生成安装计划。"""
|
|
43
|
+
config = load_config(project_root)
|
|
44
|
+
app_name = config["app_name"]
|
|
45
|
+
install_dir = Path.home() / f".{app_name}-cli"
|
|
46
|
+
link_dir = Path("/usr/local/bin") if os.access("/usr/local/bin", os.W_OK) else Path.home() / ".local" / "bin"
|
|
47
|
+
return InstallPlan(
|
|
48
|
+
app_name=app_name,
|
|
49
|
+
install_dir=install_dir,
|
|
50
|
+
exec_path=install_dir / app_name,
|
|
51
|
+
current_dir=install_dir / "current",
|
|
52
|
+
link_dir=link_dir,
|
|
53
|
+
link_path=link_dir / app_name,
|
|
54
|
+
build_dist_dir=project_root / config["build_root"] / "dist",
|
|
55
|
+
entry_path=project_root / config["entry"],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def source_module_name(entry: str) -> str | None:
|
|
60
|
+
"""把 `pkg/__main__.py` 转成 `pkg`,其余入口返回 `None`。"""
|
|
61
|
+
if entry.endswith("/__main__.py"):
|
|
62
|
+
return entry[: -len("/__main__.py")].replace("/", ".")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def install_project(project_root: Path, version: str | None = None) -> InstallPlan:
|
|
67
|
+
"""安装源码入口或指定版本产物,并创建稳定符号链接。"""
|
|
68
|
+
plan = build_install_plan(project_root)
|
|
69
|
+
plan.install_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
plan.link_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if version:
|
|
72
|
+
_install_product(plan, version)
|
|
73
|
+
else:
|
|
74
|
+
_install_source(project_root, plan)
|
|
75
|
+
if plan.link_path.exists() or plan.link_path.is_symlink():
|
|
76
|
+
plan.link_path.unlink()
|
|
77
|
+
plan.link_path.symlink_to(plan.exec_path)
|
|
78
|
+
return plan
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _install_source(project_root: Path, plan: InstallPlan) -> None:
|
|
82
|
+
"""安装源码入口包装脚本。"""
|
|
83
|
+
config = load_config(project_root)
|
|
84
|
+
module_name = source_module_name(config["entry"])
|
|
85
|
+
if not plan.entry_path.is_file():
|
|
86
|
+
raise FileNotFoundError(f"源码入口不存在:{config['entry']}")
|
|
87
|
+
if module_name:
|
|
88
|
+
body = _python_wrapper(
|
|
89
|
+
(
|
|
90
|
+
"import os, subprocess, sys\n"
|
|
91
|
+
f'os.chdir({project_root.as_posix()!r})\n'
|
|
92
|
+
f"raise SystemExit(subprocess.call([sys.executable, '-m', {module_name!r}, *sys.argv[1:]]))\n"
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
body = _python_wrapper(
|
|
97
|
+
(
|
|
98
|
+
"import os, subprocess, sys\n"
|
|
99
|
+
f'os.chdir({project_root.as_posix()!r})\n'
|
|
100
|
+
f"raise SystemExit(subprocess.call([sys.executable, {str(plan.entry_path)!r}, *sys.argv[1:]]))\n"
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
plan.exec_path.write_text(body, encoding="utf-8")
|
|
104
|
+
plan.exec_path.chmod(0o755)
|
|
105
|
+
if plan.current_dir.exists():
|
|
106
|
+
shutil.rmtree(plan.current_dir)
|
|
107
|
+
_write_metadata(plan, installed_from="source", install_mode="source")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _install_product(plan: InstallPlan, version: str) -> None:
|
|
111
|
+
"""安装指定版本的本地 wheel 产物。"""
|
|
112
|
+
dist_dir = plan.build_dist_dir / version / "dist"
|
|
113
|
+
wheels = sorted(dist_dir.glob("*.whl")) if dist_dir.is_dir() else []
|
|
114
|
+
if not wheels:
|
|
115
|
+
raise FileNotFoundError(
|
|
116
|
+
f"指定版本产物不存在:{dist_dir},请先执行 `shipcli build`"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
import subprocess
|
|
120
|
+
import sys
|
|
121
|
+
|
|
122
|
+
subprocess.run(
|
|
123
|
+
[sys.executable, "-m", "pip", "install", "--force-reinstall", str(wheels[0]), "--disable-pip-version-check"],
|
|
124
|
+
check=True,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if plan.current_dir.exists():
|
|
128
|
+
shutil.rmtree(plan.current_dir)
|
|
129
|
+
body = _python_wrapper(
|
|
130
|
+
f"import subprocess, sys\nraise SystemExit(subprocess.call([sys.executable, '-m', {plan.app_name!r}, *sys.argv[1:]]))\n"
|
|
131
|
+
)
|
|
132
|
+
plan.exec_path.write_text(body, encoding="utf-8")
|
|
133
|
+
plan.exec_path.chmod(0o755)
|
|
134
|
+
_write_metadata(plan, installed_from=version, install_mode="wheel")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _python_wrapper(body: str) -> str:
|
|
138
|
+
"""生成稳定的 Python 包装脚本内容。"""
|
|
139
|
+
return "#!/usr/bin/env python3\n" + body
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write_metadata(plan: InstallPlan, installed_from: str, install_mode: str) -> None:
|
|
143
|
+
"""写入安装元信息,供后续 upgrade 等命令复用。"""
|
|
144
|
+
metadata = {
|
|
145
|
+
"installed_from": installed_from,
|
|
146
|
+
"build_dist_dir": str(plan.build_dist_dir),
|
|
147
|
+
"install_mode": install_mode,
|
|
148
|
+
"app_name": plan.app_name,
|
|
149
|
+
}
|
|
150
|
+
(plan.install_dir / "VERSION.installed").write_text(
|
|
151
|
+
json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
|
|
152
|
+
encoding="utf-8",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def latest_product_version(project_root: Path) -> str:
|
|
157
|
+
"""返回构建目录中最新的可安装产物版本(按 wheel/sdist 目录判断)。"""
|
|
158
|
+
plan = build_install_plan(project_root)
|
|
159
|
+
dist = plan.build_dist_dir
|
|
160
|
+
if not dist.is_dir():
|
|
161
|
+
raise FileNotFoundError(f"构建产物目录不存在:{dist}")
|
|
162
|
+
candidates = []
|
|
163
|
+
for sub in dist.iterdir():
|
|
164
|
+
if not sub.is_dir():
|
|
165
|
+
continue
|
|
166
|
+
# 方向 A:产物是 wheel/sdist,放在版本目录下的 dist/ 里。
|
|
167
|
+
if (sub / "dist").is_dir() and any((sub / "dist").glob("*.whl")):
|
|
168
|
+
candidates.append(sub.name)
|
|
169
|
+
if not candidates:
|
|
170
|
+
raise FileNotFoundError(f"未找到可安装产物:{dist}")
|
|
171
|
+
candidates.sort(key=_version_sort_key)
|
|
172
|
+
return candidates[-1]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def upgrade_project(project_root: Path, version: str | None = None) -> tuple[InstallPlan, str]:
|
|
176
|
+
"""升级当前项目的本地安装到最新或指定版本。"""
|
|
177
|
+
target_version = version or latest_product_version(project_root)
|
|
178
|
+
plan = install_project(project_root, version=target_version)
|
|
179
|
+
return plan, target_version
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def uninstall_project(project_root: Path) -> InstallPlan:
|
|
183
|
+
"""卸载当前项目的本地安装与符号链接。"""
|
|
184
|
+
plan = build_install_plan(project_root)
|
|
185
|
+
if plan.link_path.exists() or plan.link_path.is_symlink():
|
|
186
|
+
plan.link_path.unlink()
|
|
187
|
+
if plan.install_dir.exists():
|
|
188
|
+
shutil.rmtree(plan.install_dir)
|
|
189
|
+
return plan
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _version_sort_key(version_str: str) -> tuple[int, int, int, int, int]:
|
|
193
|
+
"""给 release/dev 版本字符串生成排序键(兼容 `.devN` 与 `-devN`)。"""
|
|
194
|
+
dev = 0
|
|
195
|
+
base = version_str
|
|
196
|
+
for sep in (".dev", "-dev"):
|
|
197
|
+
if sep in base:
|
|
198
|
+
base, dev_part = base.split(sep, 1)
|
|
199
|
+
dev = int(dev_part)
|
|
200
|
+
break
|
|
201
|
+
# dev 标记 0 表示 dev 版(排序靠后于 release?不:dev 应低于同版本 release)。
|
|
202
|
+
segments = base.split(".")
|
|
203
|
+
if len(segments) != 3 or not all(seg.isdigit() for seg in segments):
|
|
204
|
+
# 非标准三段版本:退化为按字符串排,确保不抛异常。
|
|
205
|
+
return (0, 0, 0, 0, 0)
|
|
206
|
+
major, minor, patch = (int(seg) for seg in segments)
|
|
207
|
+
# release(dev==0 且无 dev 标记)排在 dev 之上:用 is_release 区分。
|
|
208
|
+
is_release = 0 if (".dev" in version_str or "-dev" in version_str) else 1
|
|
209
|
+
return (major, minor, patch, is_release, dev)
|