bykpy 0.1.0__tar.gz

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.
Files changed (35) hide show
  1. bykpy-0.1.0/PKG-INFO +67 -0
  2. bykpy-0.1.0/README.md +42 -0
  3. bykpy-0.1.0/pyproject.toml +49 -0
  4. bykpy-0.1.0/setup.cfg +4 -0
  5. bykpy-0.1.0/src/bykpy/__init__.py +5 -0
  6. bykpy-0.1.0/src/bykpy/__main__.py +23 -0
  7. bykpy-0.1.0/src/bykpy/api/__init__.py +22 -0
  8. bykpy-0.1.0/src/bykpy/api/context.py +65 -0
  9. bykpy-0.1.0/src/bykpy/api/network.py +91 -0
  10. bykpy-0.1.0/src/bykpy/app.py +160 -0
  11. bykpy-0.1.0/src/bykpy/core/__init__.py +14 -0
  12. bykpy-0.1.0/src/bykpy/core/context.py +50 -0
  13. bykpy-0.1.0/src/bykpy/core/environment.py +29 -0
  14. bykpy-0.1.0/src/bykpy/core/errors.py +7 -0
  15. bykpy-0.1.0/src/bykpy/core/persistence.py +53 -0
  16. bykpy-0.1.0/src/bykpy/core/plugin.py +21 -0
  17. bykpy-0.1.0/src/bykpy/core/state.py +20 -0
  18. bykpy-0.1.0/src/bykpy/infra/__init__.py +0 -0
  19. bykpy-0.1.0/src/bykpy/infra/cache.py +113 -0
  20. bykpy-0.1.0/src/bykpy/infra/logging.py +70 -0
  21. bykpy-0.1.0/src/bykpy/infra/persistence.py +93 -0
  22. bykpy-0.1.0/src/bykpy/infra/registry.py +26 -0
  23. bykpy-0.1.0/src/bykpy/infra/state.py +53 -0
  24. bykpy-0.1.0/src/bykpy/infra/view.py +25 -0
  25. bykpy-0.1.0/src/bykpy/runtime.py +58 -0
  26. bykpy-0.1.0/src/bykpy.egg-info/PKG-INFO +67 -0
  27. bykpy-0.1.0/src/bykpy.egg-info/SOURCES.txt +33 -0
  28. bykpy-0.1.0/src/bykpy.egg-info/dependency_links.txt +1 -0
  29. bykpy-0.1.0/src/bykpy.egg-info/requires.txt +10 -0
  30. bykpy-0.1.0/src/bykpy.egg-info/top_level.txt +1 -0
  31. bykpy-0.1.0/tests/test_cli.py +34 -0
  32. bykpy-0.1.0/tests/test_network.py +131 -0
  33. bykpy-0.1.0/tests/test_persistence.py +183 -0
  34. bykpy-0.1.0/tests/test_state.py +88 -0
  35. bykpy-0.1.0/tests/test_view.py +57 -0
bykpy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: bykpy
3
+ Version: 0.1.0
4
+ Summary: Python Plugin infrastructure for bykpy
5
+ Author-email: fcbyk <731240932@qq.com>
6
+ License: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: click>=8.0.0
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest<8.0.0,>=7.0.0; extra == "test"
20
+ Requires-Dist: pytest-cov<5.0.0,>=4.0.0; extra == "test"
21
+ Provides-Extra: build
22
+ Requires-Dist: commitizen; extra == "build"
23
+ Requires-Dist: build; extra == "build"
24
+ Requires-Dist: twine; extra == "build"
25
+
26
+ # bykpy
27
+
28
+ [![Python](https://img.shields.io/badge/python-%E2%89%A53.10-blue?logo=python&logoColor=white)](https://www.python.org/downloads/)
29
+ [![License](https://img.shields.io/github/license/fcbyk/bykcli.svg)](https://github.com/fcbyk/byk/blob/main/LICENSE)
30
+
31
+ Plugin infrastructure for [byk](https://github.com/fcbyk/byk).
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install bykpy
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ Implement `PluginProtocol` and register your commands:
42
+
43
+ ```python
44
+ # my_plugin.py
45
+ import click
46
+ from bykpy import PluginProtocol
47
+
48
+ class MyPlugin(PluginProtocol):
49
+ commands = {"hello": "say hello"}
50
+
51
+ def register(self, cli: click.Group) -> None:
52
+ @cli.command()
53
+ @click.pass_context
54
+ def hello(ctx):
55
+ click.echo("Hello from my plugin!")
56
+ ```
57
+
58
+ ## API
59
+
60
+ - **PluginProtocol** — class-based plugin registration protocol
61
+ - **CommandContext / pass_command_context / get_app_context** — command runtime context
62
+ - **get_private_networks / ensure_port_available** — network utilities
63
+ - **StateStore** — persistent key-value storage per command
64
+
65
+ ## License
66
+
67
+ MIT
bykpy-0.1.0/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # bykpy
2
+
3
+ [![Python](https://img.shields.io/badge/python-%E2%89%A53.10-blue?logo=python&logoColor=white)](https://www.python.org/downloads/)
4
+ [![License](https://img.shields.io/github/license/fcbyk/bykcli.svg)](https://github.com/fcbyk/byk/blob/main/LICENSE)
5
+
6
+ Plugin infrastructure for [byk](https://github.com/fcbyk/byk).
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install bykpy
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Implement `PluginProtocol` and register your commands:
17
+
18
+ ```python
19
+ # my_plugin.py
20
+ import click
21
+ from bykpy import PluginProtocol
22
+
23
+ class MyPlugin(PluginProtocol):
24
+ commands = {"hello": "say hello"}
25
+
26
+ def register(self, cli: click.Group) -> None:
27
+ @cli.command()
28
+ @click.pass_context
29
+ def hello(ctx):
30
+ click.echo("Hello from my plugin!")
31
+ ```
32
+
33
+ ## API
34
+
35
+ - **PluginProtocol** — class-based plugin registration protocol
36
+ - **CommandContext / pass_command_context / get_app_context** — command runtime context
37
+ - **get_private_networks / ensure_port_available** — network utilities
38
+ - **StateStore** — persistent key-value storage per command
39
+
40
+ ## License
41
+
42
+ MIT
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "bykpy"
7
+ version = "0.1.0"
8
+ description = "Python Plugin infrastructure for bykpy"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ {name = "fcbyk", email = "731240932@qq.com"}
13
+ ]
14
+ license = {text = "MIT"}
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ ]
25
+ dependencies = [
26
+ "click>=8.0.0",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ test = [
31
+ "pytest>=7.0.0,<8.0.0",
32
+ "pytest-cov>=4.0.0,<5.0.0",
33
+ ]
34
+ build = [
35
+ "commitizen",
36
+ "build",
37
+ "twine",
38
+ ]
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ python_files = ["test_*.py"]
46
+ addopts = "-v --cov=bykpy --cov-report=term-missing"
47
+
48
+ [tool.pyright]
49
+ extraPaths = ["src"]
bykpy-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from bykpy.core.plugin import PluginProtocol
2
+
3
+ __version__ = "0.1.0"
4
+
5
+
@@ -0,0 +1,23 @@
1
+ """支持 ``python -m bykpy`` 运行。"""
2
+
3
+ import sys
4
+
5
+
6
+ if __name__ == "__main__":
7
+ # --scan-plugins: Rust 调用的无头模式,仅扫描插件并写缓存,不启动 CLI
8
+ if len(sys.argv) >= 2 and sys.argv[1] == "--scan-plugins":
9
+ from pathlib import Path
10
+
11
+ from bykpy.infra.cache import _build_cache
12
+ from bykpy.infra.persistence import write_json
13
+
14
+ cache_dir = Path.home() / ".byk" / "cache"
15
+ cache_dir.mkdir(parents=True, exist_ok=True)
16
+
17
+ data = _build_cache()
18
+ write_json(cache_dir / "app.json", data)
19
+ sys.exit(0)
20
+
21
+ from bykpy.app import create_cli
22
+
23
+ create_cli()()
@@ -0,0 +1,22 @@
1
+ """面向子命令和插件的公开运行时 API。"""
2
+
3
+ from bykpy.api.context import CommandContext, pass_command_context, get_app_context
4
+
5
+ from bykpy.api.network import (
6
+ get_private_networks,
7
+ ensure_port_available,
8
+ )
9
+
10
+ from bykpy.core.state import StateStore
11
+
12
+ __all__ = [
13
+ # 上下文
14
+ "CommandContext",
15
+ "pass_command_context",
16
+ "get_app_context",
17
+ # 网络工具
18
+ "get_private_networks",
19
+ "ensure_port_available",
20
+ # 状态存储
21
+ "StateStore",
22
+ ]
@@ -0,0 +1,65 @@
1
+ """面向子命令的上下文封装。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from functools import update_wrapper
7
+ import logging
8
+ from typing import Any, Callable, TypeVar, cast
9
+
10
+ import click
11
+
12
+ from bykpy.app import CliState
13
+ from bykpy.core.context import AppContext, CommandContextLike
14
+ from bykpy.core.state import StateStore
15
+
16
+ F = TypeVar("F", bound=Callable[..., Any])
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class CommandContext(CommandContextLike):
21
+ """提供给子命令的便捷上下文。"""
22
+
23
+ name: str
24
+ app: AppContext
25
+ logger: logging.Logger
26
+ _state_cache: dict[str, StateStore] = field(default_factory=dict, init=False, repr=False)
27
+
28
+ def state(self, name: str = "state") -> StateStore:
29
+ """获取当前命令的专属存储。"""
30
+ if name not in self._state_cache:
31
+ self._state_cache[name] = self.app.command_store(self.name, name)
32
+ return self._state_cache[name]
33
+
34
+ def _get_group_name(ctx: click.Context) -> str:
35
+ """获取二级命令名"""
36
+ node = ctx
37
+ while node.parent and node.parent.parent is not None:
38
+ node = node.parent
39
+ return node.command.name or "unknown"
40
+
41
+ def build_command_context(ctx: click.Context) -> CommandContext:
42
+ """根据当前 Click 上下文构建命令上下文"""
43
+ state = cast(CliState, ctx.obj)
44
+ command_name = _get_group_name(ctx)
45
+ return CommandContext(
46
+ name=command_name,
47
+ app=state.context,
48
+ logger=state.context.get_command_logger(command_name),
49
+ )
50
+
51
+
52
+ def pass_command_context(func: F) -> F:
53
+ """向子命令注入便捷上下文。"""
54
+
55
+ @click.pass_context
56
+ def new_func(ctx: click.Context, *args: Any, **kwargs: Any) -> Any:
57
+ return ctx.invoke(func, build_command_context(ctx), *args, **kwargs)
58
+
59
+ return cast(F, update_wrapper(new_func, func))
60
+
61
+
62
+ def get_app_context() -> AppContext:
63
+ """获取当前应用上下文。"""
64
+ from bykpy.runtime import build_runtime
65
+ return build_runtime()
@@ -0,0 +1,91 @@
1
+ """网络工具函数。"""
2
+ import socket
3
+ from typing import List, Dict
4
+
5
+
6
+ # 网络接口分类规则:(关键词列表,类型名称,是否虚拟接口,优先级)
7
+ IFACE_RULES = [
8
+ (['vmware', 'vmnet'], 'vmware', True, 30),
9
+ (['vbox', 'virtualbox'], 'virtualbox', True, 30),
10
+ (['docker', 'wsl'], 'container', True, 40),
11
+ (['bluetooth'], 'bluetooth', True, 60),
12
+ (['ethernet', '以太网'], 'ethernet', False, 10),
13
+ (['wlan', 'wi-fi', '无线'], 'wifi', False, 10),
14
+ (['loopback'], 'loopback', True, 100),
15
+ ]
16
+
17
+
18
+ def detect_iface_type(iface: str) -> tuple[str, bool, int]:
19
+ """检测网络接口类型。"""
20
+ lname = iface.lower()
21
+ for keywords, t, virtual, prio in IFACE_RULES:
22
+ if any(k in lname for k in keywords):
23
+ return t, virtual, prio
24
+ return 'unknown', False, 50
25
+
26
+
27
+ def get_private_networks() -> List[Dict]:
28
+ """获取所有局域网 IP 地址信息。"""
29
+ try:
30
+ import psutil
31
+ except ImportError:
32
+ return [{
33
+ "iface": "localhost",
34
+ "ips": ["127.0.0.1"],
35
+ "type": "loopback",
36
+ "virtual": True,
37
+ "priority": 100
38
+ }]
39
+
40
+ results = []
41
+ interfaces = psutil.net_if_addrs()
42
+
43
+ for iface, addrs in interfaces.items():
44
+ ips = []
45
+ iface_type, is_virtual, priority = detect_iface_type(iface)
46
+
47
+ for addr in addrs:
48
+ if addr.family != socket.AF_INET:
49
+ continue
50
+
51
+ ip = addr.address
52
+
53
+ # 跳过回环地址和链路本地地址
54
+ if ip.startswith('127.'):
55
+ continue
56
+ if ip.startswith('169.254.'):
57
+ continue
58
+ # 只保留私有 IP 地址
59
+ if ip.startswith(('10.', '192.168.', '172.')):
60
+ ips.append(ip)
61
+
62
+ if ips:
63
+ results.append({
64
+ "iface": iface,
65
+ "ips": ips,
66
+ "type": iface_type,
67
+ "virtual": is_virtual,
68
+ "priority": priority
69
+ })
70
+
71
+ # 按优先级排序(数字越小优先级越高)
72
+ results.sort(key=lambda x: x['priority'])
73
+
74
+ # 如果没有找到任何局域网 IP,返回 localhost
75
+ if not results:
76
+ results.append({
77
+ "iface": "localhost",
78
+ "ips": ["127.0.0.1"],
79
+ "type": "loopback",
80
+ "virtual": True,
81
+ "priority": 100
82
+ })
83
+
84
+ return results
85
+
86
+
87
+ def ensure_port_available(port: int, host: str = "0.0.0.0") -> None:
88
+ """检查端口是否可用。"""
89
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
90
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
91
+ s.bind((host, int(port)))
@@ -0,0 +1,160 @@
1
+ """CLI 应用装配。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from bykpy.core.context import AppContext
13
+ from bykpy.core.errors import CliError
14
+
15
+ logger = logging.getLogger("bykpy")
16
+
17
+
18
+ def _resolve_log_path(ctx: click.Context) -> str:
19
+ """解析日志文件绝对路径,提供健壮的 fallback。"""
20
+ try:
21
+ if ctx.obj is not None:
22
+ paths = ctx.obj.context.paths
23
+ if paths is not None:
24
+ return str(paths.app_log_file)
25
+ except Exception:
26
+ pass
27
+
28
+ # 运行时未初始化时的备选路径
29
+ return str(Path.home() / ".byk" / "logs" / "app.log")
30
+
31
+
32
+ class PluginAwareGroup(click.Group):
33
+ """支持插件动态加载的命令组。"""
34
+
35
+ runtime_provider: Any = None
36
+
37
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
38
+ return super().parse_args(ctx, args)
39
+
40
+ def resolve_command(
41
+ self,
42
+ ctx: click.Context,
43
+ args: list[str],
44
+ ) -> tuple[str | None, click.Command | None, list[str]]:
45
+ orig_args = list(args)
46
+ try:
47
+ return super().resolve_command(ctx, args)
48
+ except click.UsageError:
49
+ pass
50
+
51
+ if not orig_args:
52
+ return super().resolve_command(ctx, orig_args)
53
+
54
+ context = self._get_context(ctx)
55
+
56
+ # 检查缓存中的插件命令,按需加载单个插件
57
+ from bykpy.infra.cache import load_cache
58
+ from bykpy.infra.registry import load_single_plugin
59
+
60
+ cache_data = load_cache(context.paths.cache_dir / "app.json")
61
+ if orig_args[0] in cache_data.get("commands", {}):
62
+ cmd_name = orig_args[0]
63
+ module_path = cache_data["commands"][cmd_name]["module"]
64
+ load_single_plugin(self, cmd_name, module_path)
65
+ return super().resolve_command(ctx, orig_args)
66
+
67
+ # 未知命令
68
+ raise click.UsageError(f"Unknown command: {orig_args[0]}")
69
+
70
+ def _get_context(self, ctx: click.Context) -> AppContext:
71
+ """在命令解析阶段获取运行时上下文。"""
72
+ if ctx.obj is not None:
73
+ return ctx.obj.context
74
+ if callable(self.runtime_provider):
75
+ return self.runtime_provider() # type: ignore[return-type]
76
+ raise click.ClickException("Runtime context not yet initialized")
77
+
78
+ def invoke(self, ctx: click.Context) -> Any:
79
+ """统一兜底未处理异常"""
80
+ try:
81
+ return super().invoke(ctx)
82
+ except click.ClickException:
83
+ raise
84
+ except click.exceptions.Exit:
85
+ raise
86
+ except CliError as exc:
87
+ logger.warning("cli error: %s", exc)
88
+ raise click.ClickException(str(exc)) from exc
89
+ except SystemExit:
90
+ raise
91
+ except Exception as exc: # noqa: BLE001
92
+ logger.exception("unexpected cli error")
93
+ log_file = _resolve_log_path(ctx)
94
+ raise click.ClickException(
95
+ f"Unexpected error occurred, see logs at: {log_file}"
96
+ ) from exc
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class CliState:
101
+ """Click 根命令共享状态。"""
102
+
103
+ context: AppContext
104
+
105
+
106
+ def version_callback(
107
+ ctx: click.Context,
108
+ _param: click.Parameter,
109
+ value: bool,
110
+ ) -> None:
111
+ """Show version and exit."""
112
+ if not value or ctx.resilient_parsing:
113
+ return
114
+
115
+ from bykpy.runtime import build_runtime
116
+
117
+ app_context: AppContext = ctx.obj.context if ctx.obj else build_runtime()
118
+ click.echo(f"v{app_context.version}")
119
+ ctx.exit()
120
+
121
+
122
+ def create_cli() -> click.Group:
123
+ """创建根 CLI 对象。"""
124
+
125
+ from bykpy.runtime import build_runtime
126
+
127
+ runtime: AppContext | None = None
128
+
129
+ def get_runtime() -> AppContext:
130
+ nonlocal runtime
131
+ if runtime is None:
132
+ runtime = build_runtime()
133
+ return runtime
134
+
135
+ @click.group(
136
+ cls=PluginAwareGroup,
137
+ context_settings={"help_option_names": ["-h", "--help"]},
138
+ add_help_option=False,
139
+ invoke_without_command=True,
140
+ )
141
+ @click.option(
142
+ "--version",
143
+ "-v",
144
+ is_flag=True,
145
+ callback=version_callback,
146
+ expose_value=False,
147
+ is_eager=True,
148
+ help="Show version and exit.",
149
+ )
150
+ @click.pass_context
151
+ def cli(ctx: click.Context) -> None:
152
+ ctx.obj = CliState(context=get_runtime())
153
+ ctx.obj.context.logger.info("BYK v%s started", ctx.obj.context.version)
154
+ if ctx.invoked_subcommand is None:
155
+ from bykpy.infra.view import render_dashboard
156
+
157
+ render_dashboard(ctx.obj.context, cli)
158
+
159
+ cli.runtime_provider = get_runtime
160
+ return cli
@@ -0,0 +1,14 @@
1
+ """核心能力模块。"""
2
+
3
+ from bykpy.core.context import AppContext, CommandContextLike
4
+ from bykpy.core.environment import EnvironmentInfo
5
+ from bykpy.core.persistence import PathLayout
6
+ from bykpy.core.state import StateStore
7
+
8
+ __all__ = [
9
+ "AppContext",
10
+ "CommandContextLike",
11
+ "EnvironmentInfo",
12
+ "PathLayout",
13
+ "StateStore",
14
+ ]
@@ -0,0 +1,50 @@
1
+ """CLI 运行时上下文。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import logging
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+ from bykpy.core.environment import EnvironmentInfo
10
+ from bykpy.core.persistence import PathLayout
11
+
12
+ if TYPE_CHECKING:
13
+ from bykpy.core.state import StateStore
14
+
15
+
16
+ class CommandContextLike(Protocol):
17
+ """命令上下文协议。"""
18
+
19
+ name: str
20
+ app: "AppContext"
21
+ logger: logging.Logger
22
+
23
+ def state(self, name: str = "state") -> "StateStore": ...
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class AppContext:
28
+ """子命令共享的核心上下文。"""
29
+
30
+ app_name: str
31
+ version: str
32
+ paths: PathLayout
33
+ environment: EnvironmentInfo
34
+ logger: logging.Logger
35
+
36
+ def command_store(
37
+ self,
38
+ command_name: str,
39
+ name: str = "state",
40
+ ) -> StateStore:
41
+ """返回某个子命令的状态存储。"""
42
+ raise NotImplementedError
43
+
44
+ def store(self, name: str = "byk.config") -> StateStore:
45
+ """返回应用级共享状态存储。"""
46
+ raise NotImplementedError
47
+
48
+ def get_command_logger(self, command_name: str) -> logging.Logger:
49
+ """获取命令专属 logger。"""
50
+ raise NotImplementedError
@@ -0,0 +1,29 @@
1
+ """运行环境信息。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import platform
7
+ import sys
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class EnvironmentInfo:
12
+ """CLI 运行环境信息。"""
13
+
14
+ app_name: str
15
+ version: str
16
+ python_version: str
17
+ executable: str
18
+ platform_name: str
19
+
20
+
21
+ def collect_environment(app_name: str, version: str) -> EnvironmentInfo:
22
+ """收集当前运行环境。"""
23
+ return EnvironmentInfo(
24
+ app_name=app_name,
25
+ version=version,
26
+ python_version=platform.python_version(),
27
+ executable=sys.executable,
28
+ platform_name=platform.platform(),
29
+ )
@@ -0,0 +1,7 @@
1
+ """统一异常定义。"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CliError(RuntimeError):
7
+ """面向终端用户的业务异常。"""
@@ -0,0 +1,53 @@
1
+ """持久化路径布局。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class PathLayout:
11
+ """持久化目录布局数据结构。"""
12
+
13
+ root_dir: Path
14
+ state_dir: Path
15
+ logs_dir: Path
16
+ runtime_dir: Path
17
+ app_log_file: Path
18
+ cache_dir: Path
19
+
20
+ @staticmethod
21
+ def _validate_safe_name(value: str, field_name: str) -> str:
22
+ """校验名称安全性,防止路径穿越和脏数据文件名。"""
23
+ normalized = value.strip()
24
+ if not normalized:
25
+ raise ValueError(f"{field_name} 不能为空")
26
+ if normalized in {".", ".."}:
27
+ raise ValueError(f"{field_name} 不能为 '.' 或 '..'")
28
+ if "/" in normalized or "\\" in normalized:
29
+ raise ValueError(f"{field_name} 不能包含路径分隔符")
30
+ return normalized
31
+
32
+ def command_state_dir(self, command_name: str) -> Path:
33
+ """返回子命令专属目录。"""
34
+ safe_command_name = self._validate_safe_name(command_name, "command_name")
35
+ path = self.state_dir / safe_command_name
36
+ path.mkdir(parents=True, exist_ok=True)
37
+ return path
38
+
39
+ def command_state_file(self, command_name: str, *parts: str) -> Path:
40
+ """返回子命令专属文件路径。"""
41
+ target = self.command_state_dir(command_name)
42
+ for part in parts:
43
+ safe_part = self._validate_safe_name(part, "part")
44
+ target = target / safe_part
45
+ target.parent.mkdir(parents=True, exist_ok=True)
46
+ return target
47
+
48
+ def state_file(self, name: str) -> Path:
49
+ """返回应用级状态文件路径。"""
50
+ safe_name = self._validate_safe_name(name, "name")
51
+ path = self.state_dir / f"{safe_name}.json"
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ return path