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
shipcli/scaffold.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""shipcli 项目脚手架生成器。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def default_package_name(app_name: str) -> str:
|
|
11
|
+
"""把应用名规范化为可用的 Python 包名。"""
|
|
12
|
+
package = app_name.strip().lower().replace("-", "_").replace(" ", "_")
|
|
13
|
+
package = re.sub(r"[^a-z0-9_]", "", package)
|
|
14
|
+
package = re.sub(r"_+", "_", package).strip("_")
|
|
15
|
+
return package or "mycli"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_project(
|
|
19
|
+
target_dir: Path,
|
|
20
|
+
app_name: str,
|
|
21
|
+
package_name: str,
|
|
22
|
+
force: bool = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
"""创建一个新的 CLI 项目骨架。"""
|
|
25
|
+
if target_dir.exists() and any(target_dir.iterdir()) and not force:
|
|
26
|
+
raise FileExistsError(f"目标目录非空:{target_dir}")
|
|
27
|
+
|
|
28
|
+
package_dir = target_dir / package_name
|
|
29
|
+
commands_dir = package_dir / "commands"
|
|
30
|
+
tests_dir = target_dir / "tests"
|
|
31
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
tests_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
config = {
|
|
36
|
+
"app_name": app_name,
|
|
37
|
+
"entry": f"{package_name}/__main__.py",
|
|
38
|
+
"build_root": ".build",
|
|
39
|
+
"version": "0.1.0",
|
|
40
|
+
"build": 0,
|
|
41
|
+
}
|
|
42
|
+
(target_dir / "build.config.json").write_text(
|
|
43
|
+
json.dumps(config, ensure_ascii=False, indent=2) + "\n",
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
)
|
|
46
|
+
(target_dir / "pyproject.toml").write_text(
|
|
47
|
+
_pyproject(app_name, package_name),
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
(target_dir / "README.md").write_text(
|
|
51
|
+
_readme(app_name, package_name),
|
|
52
|
+
encoding="utf-8",
|
|
53
|
+
)
|
|
54
|
+
(package_dir / "__init__.py").write_text(
|
|
55
|
+
'__all__ = ["__version__"]\n__version__ = "0.1.0"\n',
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
)
|
|
58
|
+
(package_dir / "__main__.py").write_text(
|
|
59
|
+
_main_py(package_name),
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
)
|
|
62
|
+
(package_dir / "cli.py").write_text(
|
|
63
|
+
_cli_py(app_name, package_name),
|
|
64
|
+
encoding="utf-8",
|
|
65
|
+
)
|
|
66
|
+
(commands_dir / "__init__.py").write_text("", encoding="utf-8")
|
|
67
|
+
(commands_dir / "help.py").write_text(_help_py(), encoding="utf-8")
|
|
68
|
+
(commands_dir / "version.py").write_text(
|
|
69
|
+
_version_py(app_name, package_name),
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
)
|
|
72
|
+
(commands_dir / "upgrade.py").write_text(
|
|
73
|
+
_upgrade_py(app_name),
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
(commands_dir / "uninstall.py").write_text(
|
|
77
|
+
_uninstall_py(app_name),
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
)
|
|
80
|
+
(tests_dir / "test_cli.py").write_text(
|
|
81
|
+
_test_cli(package_name),
|
|
82
|
+
encoding="utf-8",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pyproject(app_name: str, package_name: str) -> str:
|
|
87
|
+
"""生成项目 pyproject.toml。"""
|
|
88
|
+
return f"""[build-system]
|
|
89
|
+
requires = ["setuptools>=69", "wheel"]
|
|
90
|
+
build-backend = "setuptools.build_meta"
|
|
91
|
+
|
|
92
|
+
[project]
|
|
93
|
+
name = "{app_name}"
|
|
94
|
+
version = "0.1.0"
|
|
95
|
+
description = "A Python CLI initialized by shipcli."
|
|
96
|
+
requires-python = ">=3.10"
|
|
97
|
+
license = "MIT"
|
|
98
|
+
authors = [
|
|
99
|
+
{{ name = "your-name" }}
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
[project.optional-dependencies]
|
|
103
|
+
build = [
|
|
104
|
+
"build>=1.2.2"
|
|
105
|
+
]
|
|
106
|
+
publish = [
|
|
107
|
+
"twine>=5.1.0"
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[project.scripts]
|
|
111
|
+
{app_name} = "{package_name}.cli:main"
|
|
112
|
+
|
|
113
|
+
[tool.setuptools.packages.find]
|
|
114
|
+
include = ["{package_name}*"]
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def _readme(app_name: str, package_name: str) -> str:
|
|
118
|
+
"""生成项目 README。"""
|
|
119
|
+
return f"""# {app_name}
|
|
120
|
+
|
|
121
|
+
一个由 `shipcli` 初始化的 Python CLI 项目。
|
|
122
|
+
|
|
123
|
+
## 运行
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
python3 -m {package_name} --help
|
|
127
|
+
python3 -m {package_name} help
|
|
128
|
+
python3 -m {package_name} version
|
|
129
|
+
python3 -m {package_name} upgrade
|
|
130
|
+
python3 -m {package_name} uninstall
|
|
131
|
+
```
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _main_py(package_name: str) -> str:
|
|
136
|
+
"""生成模块入口文件。"""
|
|
137
|
+
return f'''"""{package_name} 模块入口。"""
|
|
138
|
+
|
|
139
|
+
from __future__ import annotations
|
|
140
|
+
|
|
141
|
+
import sys
|
|
142
|
+
|
|
143
|
+
from {package_name}.cli import main
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
|
148
|
+
'''
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _cli_py(app_name: str, package_name: str) -> str:
|
|
152
|
+
"""生成最小 CLI 分发器。"""
|
|
153
|
+
return f'''"""{package_name} CLI 主入口。"""
|
|
154
|
+
|
|
155
|
+
from __future__ import annotations
|
|
156
|
+
|
|
157
|
+
import argparse
|
|
158
|
+
import sys
|
|
159
|
+
|
|
160
|
+
from {package_name}.commands.help import run_help
|
|
161
|
+
from {package_name}.commands.uninstall import run_uninstall
|
|
162
|
+
from {package_name}.commands.upgrade import run_upgrade
|
|
163
|
+
from {package_name}.commands.version import run_version
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
167
|
+
"""构造顶层参数解析器。"""
|
|
168
|
+
parser = argparse.ArgumentParser(prog="{app_name}", description="{app_name} CLI")
|
|
169
|
+
parser.add_argument("--version", action="store_true", help="显示版本号")
|
|
170
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
171
|
+
sub.add_parser("help", help="显示帮助信息")
|
|
172
|
+
sub.add_parser("version", help="输出当前版本")
|
|
173
|
+
upgrade = sub.add_parser("upgrade", help="升级当前 CLI")
|
|
174
|
+
upgrade.add_argument("--version", default=None, help="升级到指定版本")
|
|
175
|
+
upgrade.add_argument("--local", default=None, help="从本地项目路径的 build 产物升级")
|
|
176
|
+
sub.add_parser("uninstall", help="卸载当前 CLI")
|
|
177
|
+
return parser
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main(argv: list[str] | None = None) -> int:
|
|
181
|
+
"""解析命令行并分发子命令。"""
|
|
182
|
+
parser = build_parser()
|
|
183
|
+
args = parser.parse_args(argv)
|
|
184
|
+
if args.version:
|
|
185
|
+
return run_version(args)
|
|
186
|
+
if args.command == "help":
|
|
187
|
+
return run_help(parser)
|
|
188
|
+
if args.command == "version":
|
|
189
|
+
return run_version(args)
|
|
190
|
+
if args.command == "upgrade":
|
|
191
|
+
return run_upgrade(args)
|
|
192
|
+
if args.command == "uninstall":
|
|
193
|
+
return run_uninstall(args)
|
|
194
|
+
parser.print_help()
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
sys.exit(main())
|
|
200
|
+
'''
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _help_py() -> str:
|
|
204
|
+
"""生成示例 help 命令。"""
|
|
205
|
+
return '''"""示例 help 命令。"""
|
|
206
|
+
|
|
207
|
+
from __future__ import annotations
|
|
208
|
+
|
|
209
|
+
import argparse
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def run_help(parser: argparse.ArgumentParser) -> int:
|
|
213
|
+
"""输出当前 CLI 的帮助信息。"""
|
|
214
|
+
parser.print_help()
|
|
215
|
+
return 0
|
|
216
|
+
'''
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _version_py(app_name: str, package_name: str) -> str:
|
|
220
|
+
"""生成示例 version 命令。"""
|
|
221
|
+
return f'''"""示例 version 命令。"""
|
|
222
|
+
|
|
223
|
+
from __future__ import annotations
|
|
224
|
+
|
|
225
|
+
from typing import Any
|
|
226
|
+
|
|
227
|
+
from {package_name} import __version__
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def run_version(args: Any) -> int:
|
|
231
|
+
"""输出当前 CLI 的版本号。"""
|
|
232
|
+
print("{app_name} " + __version__)
|
|
233
|
+
return 0
|
|
234
|
+
'''
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _uninstall_py(app_name: str) -> str:
|
|
238
|
+
"""生成示例 uninstall 命令:pip 卸载 + 清理本地痕迹。"""
|
|
239
|
+
return f'''"""示例 uninstall 命令。"""
|
|
240
|
+
|
|
241
|
+
from __future__ import annotations
|
|
242
|
+
|
|
243
|
+
import shutil
|
|
244
|
+
import subprocess
|
|
245
|
+
import sys
|
|
246
|
+
from pathlib import Path
|
|
247
|
+
from typing import Any
|
|
248
|
+
|
|
249
|
+
APP_NAME = "{app_name}"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run_uninstall(args: Any) -> int:
|
|
253
|
+
"""卸载当前 CLI:pip 卸载包,并清理本地安装目录与符号链接。"""
|
|
254
|
+
subprocess.run(
|
|
255
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", APP_NAME, "--disable-pip-version-check"],
|
|
256
|
+
check=False,
|
|
257
|
+
)
|
|
258
|
+
install_dir = Path.home() / f".{{APP_NAME}}-cli"
|
|
259
|
+
for link_dir in (Path("/usr/local/bin"), Path.home() / ".local" / "bin"):
|
|
260
|
+
link_path = link_dir / APP_NAME
|
|
261
|
+
if link_path.exists() or link_path.is_symlink():
|
|
262
|
+
link_path.unlink(missing_ok=True)
|
|
263
|
+
if install_dir.exists():
|
|
264
|
+
shutil.rmtree(install_dir)
|
|
265
|
+
print(f"已卸载:{{APP_NAME}}")
|
|
266
|
+
return 0
|
|
267
|
+
'''
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _upgrade_py(app_name: str) -> str:
|
|
271
|
+
"""生成示例 upgrade 命令:默认 PyPI,--local 走本地 build 产物。"""
|
|
272
|
+
return f'''"""示例 upgrade 命令。"""
|
|
273
|
+
|
|
274
|
+
from __future__ import annotations
|
|
275
|
+
|
|
276
|
+
import json
|
|
277
|
+
import subprocess
|
|
278
|
+
import sys
|
|
279
|
+
from pathlib import Path
|
|
280
|
+
from typing import Any
|
|
281
|
+
|
|
282
|
+
APP_NAME = "{app_name}"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_upgrade(args: Any) -> int:
|
|
286
|
+
"""升级当前 CLI:默认从 PyPI 拉发布版,--local 从本地 build 产物装。"""
|
|
287
|
+
try:
|
|
288
|
+
if getattr(args, "local", None):
|
|
289
|
+
version = _upgrade_from_local(Path(args.local).resolve(), args.version)
|
|
290
|
+
else:
|
|
291
|
+
version = _upgrade_from_pypi(args.version)
|
|
292
|
+
except Exception as exc: # noqa: BLE001
|
|
293
|
+
print(f"错误: {{exc}}")
|
|
294
|
+
return 1
|
|
295
|
+
print(f"升级完成:{{version}}")
|
|
296
|
+
return 0
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _upgrade_from_pypi(version: str | None) -> str:
|
|
300
|
+
"""从 PyPI 升级到最新或指定发布版本。"""
|
|
301
|
+
spec = [APP_NAME, "--upgrade"] if version is None else [f"{{APP_NAME}}=={{version}}"]
|
|
302
|
+
subprocess.run(
|
|
303
|
+
[sys.executable, "-m", "pip", "install", *spec, "--disable-pip-version-check"],
|
|
304
|
+
check=True,
|
|
305
|
+
)
|
|
306
|
+
return version or "latest"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _upgrade_from_local(project_root: Path, version: str | None) -> str:
|
|
310
|
+
"""从本地项目的 build 产物升级;version 缺省取最新版本。"""
|
|
311
|
+
resolved = version or _latest_product_version(project_root)
|
|
312
|
+
wheel = _find_local_wheel(project_root, resolved)
|
|
313
|
+
subprocess.run(
|
|
314
|
+
[sys.executable, "-m", "pip", "install", "--force-reinstall", str(wheel), "--disable-pip-version-check"],
|
|
315
|
+
check=True,
|
|
316
|
+
)
|
|
317
|
+
return resolved
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _find_local_wheel(project_root: Path, version: str) -> Path:
|
|
321
|
+
"""定位本地某版本 build 产物中的 wheel 文件。"""
|
|
322
|
+
config_path = project_root / "build.config.json"
|
|
323
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
324
|
+
build_root = config.get("build_root", ".build")
|
|
325
|
+
dist_dir = project_root / build_root / "dist" / version / "dist"
|
|
326
|
+
wheels = sorted(dist_dir.glob("*.whl")) if dist_dir.is_dir() else []
|
|
327
|
+
if not wheels:
|
|
328
|
+
raise FileNotFoundError(
|
|
329
|
+
f"未找到本地 {{version}} 的 wheel:{{dist_dir}},请先执行 build 构建"
|
|
330
|
+
)
|
|
331
|
+
return wheels[0]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _latest_product_version(project_root: Path) -> str:
|
|
335
|
+
"""返回本地 build 产物目录中最新的版本。"""
|
|
336
|
+
config_path = project_root / "build.config.json"
|
|
337
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
338
|
+
build_root = config.get("build_root", ".build")
|
|
339
|
+
dist = project_root / build_root / "dist"
|
|
340
|
+
if not dist.is_dir():
|
|
341
|
+
raise FileNotFoundError(f"构建产物目录不存在:{{dist}}")
|
|
342
|
+
candidates = []
|
|
343
|
+
for sub in dist.iterdir():
|
|
344
|
+
if sub.is_dir() and (sub / "dist").is_dir() and any((sub / "dist").glob("*.whl")):
|
|
345
|
+
candidates.append(sub.name)
|
|
346
|
+
if not candidates:
|
|
347
|
+
raise FileNotFoundError(f"未找到可升级产物:{{dist}}")
|
|
348
|
+
candidates.sort(key=_version_sort_key)
|
|
349
|
+
return candidates[-1]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _version_sort_key(version_str: str) -> tuple:
|
|
353
|
+
"""给 release/dev 版本字符串生成排序键(兼容 .devN 与 -devN)。"""
|
|
354
|
+
base = version_str
|
|
355
|
+
dev = 0
|
|
356
|
+
for sep in (".dev", "-dev"):
|
|
357
|
+
if sep in base:
|
|
358
|
+
base, dev_part = base.split(sep, 1)
|
|
359
|
+
dev = int(dev_part)
|
|
360
|
+
break
|
|
361
|
+
segments = base.split(".")
|
|
362
|
+
if len(segments) != 3 or not all(seg.isdigit() for seg in segments):
|
|
363
|
+
return (0, 0, 0, 0, 0)
|
|
364
|
+
major, minor, patch = (int(seg) for seg in segments)
|
|
365
|
+
is_release = 0 if (".dev" in version_str or "-dev" in version_str) else 1
|
|
366
|
+
return (major, minor, patch, is_release, dev)
|
|
367
|
+
'''
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _test_cli(package_name: str) -> str:
|
|
371
|
+
"""生成最小命令测试。"""
|
|
372
|
+
return f'''import subprocess
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_help_runs():
|
|
376
|
+
out = subprocess.run(
|
|
377
|
+
["python3", "-m", "{package_name}", "--help"],
|
|
378
|
+
capture_output=True,
|
|
379
|
+
text=True,
|
|
380
|
+
check=True,
|
|
381
|
+
)
|
|
382
|
+
text = out.stdout + out.stderr
|
|
383
|
+
assert "help" in text
|
|
384
|
+
assert "version" in text
|
|
385
|
+
assert "upgrade" in text
|
|
386
|
+
assert "uninstall" in text
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def test_help_command_runs():
|
|
390
|
+
out = subprocess.run(
|
|
391
|
+
["python3", "-m", "{package_name}", "help"],
|
|
392
|
+
capture_output=True,
|
|
393
|
+
text=True,
|
|
394
|
+
check=True,
|
|
395
|
+
)
|
|
396
|
+
text = out.stdout + out.stderr
|
|
397
|
+
assert "help" in text
|
|
398
|
+
assert "version" in text
|
|
399
|
+
assert "upgrade" in text
|
|
400
|
+
assert "uninstall" in text
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_version_runs():
|
|
404
|
+
out = subprocess.run(
|
|
405
|
+
["python3", "-m", "{package_name}", "version"],
|
|
406
|
+
capture_output=True,
|
|
407
|
+
text=True,
|
|
408
|
+
check=True,
|
|
409
|
+
)
|
|
410
|
+
assert "0.1.0" in out.stdout
|
|
411
|
+
'''
|
shipcli/version.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""shipcli 版本展示辅助。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import shipcli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def display_version() -> str:
|
|
12
|
+
"""返回打包时写入的 CLI 展示版本。"""
|
|
13
|
+
return shipcli.__version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def install_mode() -> str:
|
|
17
|
+
"""判断当前 shipcli 的安装形态:binary / editable / installed。
|
|
18
|
+
|
|
19
|
+
- binary: PyInstaller 打包的可执行文件
|
|
20
|
+
- editable: pip editable 安装,命令直接跑源码目录
|
|
21
|
+
- installed: pip 正式安装,命令跑 site-packages 里的打包快照
|
|
22
|
+
"""
|
|
23
|
+
if getattr(sys, "frozen", False):
|
|
24
|
+
return "binary"
|
|
25
|
+
|
|
26
|
+
package_file = getattr(shipcli, "__file__", None)
|
|
27
|
+
if not package_file:
|
|
28
|
+
return "installed"
|
|
29
|
+
|
|
30
|
+
package_path = Path(package_file).resolve()
|
|
31
|
+
site_packages = _site_packages()
|
|
32
|
+
in_site = any(_is_relative_to(package_path, sp) for sp in site_packages)
|
|
33
|
+
|
|
34
|
+
if not in_site:
|
|
35
|
+
# 源码目录(不在 site-packages)→ editable 开发安装。
|
|
36
|
+
return "editable"
|
|
37
|
+
|
|
38
|
+
# 在 site-packages 内:再区分 editable 与正式安装。
|
|
39
|
+
if _has_editable_marker(package_path):
|
|
40
|
+
return "editable"
|
|
41
|
+
return "installed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def display_version_with_mode() -> str:
|
|
45
|
+
"""返回带安装形态标记的版本字符串。"""
|
|
46
|
+
return f"{display_version()} ({install_mode()})"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _site_packages() -> list[Path]:
|
|
50
|
+
"""收集当前解释器的 site-packages 目录。"""
|
|
51
|
+
candidates: list[Path] = []
|
|
52
|
+
try:
|
|
53
|
+
import site
|
|
54
|
+
|
|
55
|
+
candidates.extend(Path(p).resolve() for p in site.getsitepackages())
|
|
56
|
+
candidates.append(Path(site.getusersitepackages()).resolve())
|
|
57
|
+
except Exception: # noqa: BLE001 - 退化时按 sys.path 兜底
|
|
58
|
+
pass
|
|
59
|
+
candidates.extend(Path(p).resolve() for p in sys.path if p)
|
|
60
|
+
return candidates
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _is_relative_to(path: Path, base: Path) -> bool:
|
|
64
|
+
"""兼容旧 Python 的相对路径判断。"""
|
|
65
|
+
try:
|
|
66
|
+
path.relative_to(base)
|
|
67
|
+
return True
|
|
68
|
+
except ValueError:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _has_editable_marker(package_path: Path) -> bool:
|
|
73
|
+
"""判断 site-packages 内的包是否为 editable 安装。
|
|
74
|
+
|
|
75
|
+
editable 安装会留下 `__editable__.<name>-<ver>.pth` 或
|
|
76
|
+
`<name>-<ver>.dist-info/direct_url.json`(含 "dir" 字段)。
|
|
77
|
+
"""
|
|
78
|
+
parent = package_path.parent
|
|
79
|
+
for entry in parent.iterdir():
|
|
80
|
+
name = entry.name
|
|
81
|
+
if name.startswith("__editable__") and name.endswith(".pth"):
|
|
82
|
+
return True
|
|
83
|
+
if entry.is_dir() and entry.name.endswith(".dist-info"):
|
|
84
|
+
direct_url = entry / "direct_url.json"
|
|
85
|
+
if direct_url.is_file():
|
|
86
|
+
try:
|
|
87
|
+
import json
|
|
88
|
+
|
|
89
|
+
data = json.loads(direct_url.read_text(encoding="utf-8"))
|
|
90
|
+
if "dir" in data:
|
|
91
|
+
return True
|
|
92
|
+
except Exception: # noqa: BLE001 - 解析失败按非 editable 处理
|
|
93
|
+
continue
|
|
94
|
+
return False
|
|
95
|
+
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shipcli
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Pure Python CLI scaffold and delivery tool.
|
|
5
|
+
Author: CaffeineOddity
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: cli,scaffold,pyinstaller,release,tooling
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
15
|
+
Classifier: Topic :: Utilities
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Provides-Extra: build
|
|
20
|
+
Requires-Dist: pyinstaller>=6.0; extra == "build"
|
|
21
|
+
Provides-Extra: publish
|
|
22
|
+
Requires-Dist: twine>=5.1.0; extra == "publish"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build>=1.2.2; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest>=9.0.0; extra == "dev"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# shipcli
|
|
29
|
+
|
|
30
|
+
`shipcli` 是一个纯 Python 的 CLI 脚手架与交付工具,用来初始化、构建、发布、安装和升级命令行项目。
|
|
31
|
+
|
|
32
|
+
## 特性
|
|
33
|
+
|
|
34
|
+
- 用 `shipcli init <path>` 快速生成新的 Python CLI 项目
|
|
35
|
+
- 用 `shipcli build` / `shipcli build --release` 生成 dev 或 release 版的 wheel/sdist 分发文件
|
|
36
|
+
- 用 `shipcli publish` 把 release 版本发布到 GitHub Release 或 PyPI
|
|
37
|
+
- `shipcli upgrade` / `uninstall` 只管 shipcli 自身;项目 CLI(如 demo-cli)用其自带命令管理
|
|
38
|
+
- 默认以当前目录作为项目目录,也支持 `--project <path>` 跨目录操作
|
|
39
|
+
- 初始化后的项目默认包含 `help`、`version`、`upgrade`、`uninstall` 命令示例
|
|
40
|
+
|
|
41
|
+
## 命令作用范围
|
|
42
|
+
|
|
43
|
+
| 命令 | 作用对象 |
|
|
44
|
+
|------|---------|
|
|
45
|
+
| `init` / `build` / `install` / `publish` | 目标项目(当前目录或 `--project`,如 demo-cli) |
|
|
46
|
+
| `upgrade` / `uninstall` | shipcli 自身 |
|
|
47
|
+
|
|
48
|
+
若要升级或卸载某个项目 CLI(如 demo-cli),应先 `install` 安装它,再用其自带命令:`demo-cli upgrade` / `demo-cli uninstall`。
|
|
49
|
+
|
|
50
|
+
## 安装
|
|
51
|
+
|
|
52
|
+
### 首次安装(开发)
|
|
53
|
+
|
|
54
|
+
shipcli 未发布到 PyPI 前,从本地仓库安装:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
git clone <your-repo-url>
|
|
58
|
+
cd shipcli
|
|
59
|
+
python3 -m pip install -e . # editable 安装,改代码即生效
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
安装完成后:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
shipcli --help
|
|
66
|
+
shipcli --version # 形如:shipcli 0.0.1 (editable)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`--version` / `--help` 会标注当前安装形态:`editable`(editable 安装)、`installed`(pip 正式安装)、`binary`(PyInstaller 二进制)。
|
|
70
|
+
|
|
71
|
+
### 安装 build 产物
|
|
72
|
+
|
|
73
|
+
`shipcli build` 产出 wheel/sdist 后,可直接装本地 wheel(模拟用户从 PyPI 安装):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
shipcli build --release
|
|
77
|
+
python3 -m pip install .build/dist/0.0.1/dist/*.whl
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 快速开始
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
shipcli init demo-cli
|
|
84
|
+
|
|
85
|
+
cd demo-cli
|
|
86
|
+
shipcli build # dev 构建,产出 <version>.devN
|
|
87
|
+
shipcli build --release # release 构建,产出发布版 wheel/sdist
|
|
88
|
+
shipcli publish --github --pypi # 发布到 GitHub Release + PyPI
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
也支持不切目录,直接指定目标项目:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
shipcli build --project ./demo-cli
|
|
95
|
+
shipcli publish --project ./demo-cli --github --github-repo owner/demo-cli
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 构建
|
|
99
|
+
|
|
100
|
+
`shipcli build` 产出标准的 Python 分发文件(wheel/sdist),不再依赖 PyInstaller:
|
|
101
|
+
|
|
102
|
+
- **dev 构建**(`shipcli build`):版本号 `<base>.devN`(如 `0.0.1.dev1`),build 号自动 +1。用于本地验证。
|
|
103
|
+
- **release 构建**(`shipcli build --release`):版本号取配置中的 `version`(如 `0.0.1`)。
|
|
104
|
+
- **版本递增**(`--increase <major|minor|patch>`):构建前把版本号指定位 +1(低位归零)并重置 build,可与 `--release` 同用。
|
|
105
|
+
|
|
106
|
+
版本号唯一真源是 `build.config.json` 的 `version`;release 构建会自动同步到 `pyproject.toml`,构建时会写入包 `__init__.py` 的 `__version__`。
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
shipcli build # dev 构建
|
|
110
|
+
shipcli build --release # release 构建
|
|
111
|
+
shipcli build --increase patch # patch+1 后 dev 构建
|
|
112
|
+
shipcli build --increase minor --release # minor+1 后 release 构建
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
产物目录结构:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
.build/dist/<version>/dist/
|
|
119
|
+
├── <name>-<version>-py3-none-any.whl
|
|
120
|
+
└── <name>-<version>.tar.gz
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## 升级 shipcli 自身
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
shipcli upgrade # 从 PyPI 升级到最新发布版
|
|
127
|
+
shipcli upgrade --version 0.0.1 # 从 PyPI 升级到指定发布版
|
|
128
|
+
shipcli upgrade --local . # 从本地项目 build 产物升级(取最新版本)
|
|
129
|
+
shipcli upgrade --local . --version 0.0.1.dev1 # 指定本地 build 产物版本
|
|
130
|
+
shipcli uninstall # 卸载 shipcli(pip 卸载 + 清理本地痕迹)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
dev 版不上 PyPI,仅本地安装/升级:先 `shipcli build` 产出 dev wheel,再用 `shipcli upgrade --local .` 装上。
|
|
134
|
+
|
|
135
|
+
## 发布
|
|
136
|
+
|
|
137
|
+
- `shipcli publish` 只做发布,不负责构建
|
|
138
|
+
- 发布前先执行 `shipcli build --release`
|
|
139
|
+
- 发布到 GitHub Release:上传 wheel/sdist 及各自的 `.sha256` 校验文件
|
|
140
|
+
- 发布到 PyPI:上传 wheel/sdist
|
|
141
|
+
- GitHub 凭证只从环境变量读取:`SHIPCLI_GITHUB_TOKEN` 或 `GITHUB_TOKEN`
|
|
142
|
+
- GitHub 仓库可通过 `--github-repo` 传入,或设置 `SHIPCLI_GITHUB_REPO` / `GITHUB_REPOSITORY`
|
|
143
|
+
- PyPI 凭证只从环境变量读取:`SHIPCLI_PYPI_TOKEN`,也兼容现有 `TWINE_PASSWORD`
|
|
144
|
+
- `publish` 只支持 release 版本,不接受 dev 版本
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
cd demo-cli
|
|
148
|
+
|
|
149
|
+
# 发布本地已构建的 release 分发文件到 GitHub Release + PyPI
|
|
150
|
+
shipcli build --release
|
|
151
|
+
SHIPCLI_GITHUB_TOKEN=... SHIPCLI_PYPI_TOKEN=... \
|
|
152
|
+
shipcli publish --github --pypi --github-repo owner/demo-cli
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## 初始化后的目录结构
|
|
156
|
+
|
|
157
|
+
执行 `shipcli init demo-cli` 后,默认会生成:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
demo-cli/
|
|
161
|
+
├── build.config.json
|
|
162
|
+
├── README.md
|
|
163
|
+
├── demo_cli/
|
|
164
|
+
│ ├── __init__.py
|
|
165
|
+
│ ├── __main__.py
|
|
166
|
+
│ ├── cli.py
|
|
167
|
+
│ └── commands/
|
|
168
|
+
│ ├── __init__.py
|
|
169
|
+
│ ├── help.py
|
|
170
|
+
│ ├── version.py
|
|
171
|
+
│ ├── upgrade.py
|
|
172
|
+
│ └── uninstall.py
|
|
173
|
+
└── tests/
|
|
174
|
+
└── test_cli.py
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## 开发验证
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python3 -m pytest
|
|
181
|
+
shipcli build --release
|
|
182
|
+
```
|