bykcli-plugin 1.0.0a5__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.
- bykcli/__init__.py +5 -0
- bykcli/__main__.py +23 -0
- bykcli/api/__init__.py +35 -0
- bykcli/api/context.py +65 -0
- bykcli/api/network.py +91 -0
- bykcli/api/paths.py +30 -0
- bykcli/app.py +151 -0
- bykcli/core/__init__.py +14 -0
- bykcli/core/context.py +50 -0
- bykcli/core/environment.py +29 -0
- bykcli/core/errors.py +7 -0
- bykcli/core/persistence.py +53 -0
- bykcli/core/plugin.py +21 -0
- bykcli/core/state.py +20 -0
- bykcli/infra/__init__.py +0 -0
- bykcli/infra/cache.py +109 -0
- bykcli/infra/logging.py +70 -0
- bykcli/infra/persistence.py +93 -0
- bykcli/infra/registry.py +26 -0
- bykcli/infra/state.py +53 -0
- bykcli/infra/view.py +25 -0
- bykcli/runtime.py +58 -0
- bykcli_plugin-1.0.0a5.dist-info/METADATA +73 -0
- bykcli_plugin-1.0.0a5.dist-info/RECORD +27 -0
- bykcli_plugin-1.0.0a5.dist-info/WHEEL +5 -0
- bykcli_plugin-1.0.0a5.dist-info/licenses/LICENSE +21 -0
- bykcli_plugin-1.0.0a5.dist-info/top_level.txt +1 -0
bykcli/__init__.py
ADDED
bykcli/__main__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""支持 ``python -m bykcli`` 运行。"""
|
|
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 bykcli.infra.cache import _build_cache
|
|
12
|
+
from bykcli.infra.persistence import write_json
|
|
13
|
+
|
|
14
|
+
cache_dir = Path.home() / ".bykcli" / "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 bykcli.app import create_cli
|
|
22
|
+
|
|
23
|
+
create_cli()()
|
bykcli/api/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""面向子命令和插件的公开运行时 API。"""
|
|
2
|
+
|
|
3
|
+
from bykcli.api.context import CommandContext, pass_command_context, get_app_context
|
|
4
|
+
from bykcli.api.paths import (
|
|
5
|
+
PathItem,
|
|
6
|
+
PathProvider,
|
|
7
|
+
register_path_provider,
|
|
8
|
+
get_path_provider,
|
|
9
|
+
global_path_items,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from bykcli.api.network import (
|
|
13
|
+
get_private_networks,
|
|
14
|
+
ensure_port_available,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from bykcli.core.state import StateStore
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
# 上下文
|
|
21
|
+
"CommandContext",
|
|
22
|
+
"pass_command_context",
|
|
23
|
+
"get_app_context",
|
|
24
|
+
# 路径管理
|
|
25
|
+
"PathItem",
|
|
26
|
+
"PathProvider",
|
|
27
|
+
"register_path_provider",
|
|
28
|
+
"get_path_provider",
|
|
29
|
+
"global_path_items",
|
|
30
|
+
# 网络工具
|
|
31
|
+
"get_private_networks",
|
|
32
|
+
"ensure_port_available",
|
|
33
|
+
# 状态存储
|
|
34
|
+
"StateStore",
|
|
35
|
+
]
|
bykcli/api/context.py
ADDED
|
@@ -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 bykcli.app import CliState
|
|
13
|
+
from bykcli.core.context import AppContext, CommandContextLike
|
|
14
|
+
from bykcli.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 bykcli.runtime import build_runtime
|
|
65
|
+
return build_runtime()
|
bykcli/api/network.py
ADDED
|
@@ -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)))
|
bykcli/api/paths.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""路径展示注册与渲染。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from bykcli.core.context import AppContext
|
|
8
|
+
|
|
9
|
+
PathItem = tuple[str, str]
|
|
10
|
+
PathProvider = Callable[[AppContext], list[PathItem]]
|
|
11
|
+
|
|
12
|
+
_PATH_PROVIDERS: dict[str, PathProvider] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register_path_provider(command_name: str, provider: PathProvider) -> None:
|
|
16
|
+
"""注册子命令路径提供器。"""
|
|
17
|
+
_PATH_PROVIDERS[command_name] = provider
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_path_provider(command_name: str) -> PathProvider | None:
|
|
21
|
+
"""获取某个子命令的路径提供器。"""
|
|
22
|
+
return _PATH_PROVIDERS.get(command_name)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def global_path_items(context: AppContext) -> list[PathItem]:
|
|
26
|
+
"""返回默认展示的全局路径。"""
|
|
27
|
+
return [
|
|
28
|
+
("CLI Home", str(context.paths.root_dir)),
|
|
29
|
+
("Logs Directory", str(context.paths.logs_dir)),
|
|
30
|
+
]
|
bykcli/app.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""CLI 应用装配。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from bykcli.core.context import AppContext
|
|
12
|
+
from bykcli.core.errors import CliError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("bykcli")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PluginAwareGroup(click.Group):
|
|
18
|
+
"""支持插件动态加载的命令组。"""
|
|
19
|
+
|
|
20
|
+
runtime_provider: Any = None
|
|
21
|
+
|
|
22
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
23
|
+
return super().parse_args(ctx, args)
|
|
24
|
+
|
|
25
|
+
def resolve_command(
|
|
26
|
+
self,
|
|
27
|
+
ctx: click.Context,
|
|
28
|
+
args: list[str],
|
|
29
|
+
) -> tuple[str | None, click.Command | None, list[str]]:
|
|
30
|
+
orig_args = list(args)
|
|
31
|
+
try:
|
|
32
|
+
return super().resolve_command(ctx, args)
|
|
33
|
+
except click.UsageError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
if not orig_args:
|
|
37
|
+
return super().resolve_command(ctx, orig_args)
|
|
38
|
+
|
|
39
|
+
context = self._get_context(ctx)
|
|
40
|
+
|
|
41
|
+
# 检查缓存中的插件命令,按需加载单个插件
|
|
42
|
+
from bykcli.infra.cache import load_cache
|
|
43
|
+
from bykcli.infra.registry import load_single_plugin
|
|
44
|
+
|
|
45
|
+
cache_data = load_cache(context.paths.cache_dir / "app.json")
|
|
46
|
+
if orig_args[0] in cache_data.get("commands", {}):
|
|
47
|
+
cmd_name = orig_args[0]
|
|
48
|
+
module_path = cache_data["commands"][cmd_name]["module"]
|
|
49
|
+
load_single_plugin(self, cmd_name, module_path)
|
|
50
|
+
return super().resolve_command(ctx, orig_args)
|
|
51
|
+
|
|
52
|
+
# 未知命令
|
|
53
|
+
raise click.UsageError(f"Unknown command: {orig_args[0]}")
|
|
54
|
+
|
|
55
|
+
def _get_context(self, ctx: click.Context) -> AppContext:
|
|
56
|
+
"""在命令解析阶段获取运行时上下文。"""
|
|
57
|
+
if ctx.obj is not None:
|
|
58
|
+
return ctx.obj.context
|
|
59
|
+
if callable(self.runtime_provider):
|
|
60
|
+
return self.runtime_provider() # type: ignore[return-type]
|
|
61
|
+
raise click.ClickException("Runtime context not yet initialized")
|
|
62
|
+
|
|
63
|
+
def invoke(self, ctx: click.Context) -> Any:
|
|
64
|
+
"""统一兜底未处理异常"""
|
|
65
|
+
try:
|
|
66
|
+
return super().invoke(ctx)
|
|
67
|
+
except click.ClickException:
|
|
68
|
+
raise
|
|
69
|
+
except click.exceptions.Exit:
|
|
70
|
+
raise
|
|
71
|
+
except CliError as exc:
|
|
72
|
+
logger.warning("cli error: %s", exc)
|
|
73
|
+
raise click.ClickException(str(exc)) from exc
|
|
74
|
+
except SystemExit:
|
|
75
|
+
raise
|
|
76
|
+
except Exception as exc: # noqa: BLE001
|
|
77
|
+
logger.exception("unexpected cli error")
|
|
78
|
+
log_file = "log file"
|
|
79
|
+
try:
|
|
80
|
+
if ctx.obj and hasattr(ctx.obj, 'context'):
|
|
81
|
+
log_file = getattr(ctx.obj.context.paths, 'app_log_file', 'log file')
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
raise click.ClickException(
|
|
86
|
+
f"Unexpected error occurred, see logs at: {log_file}"
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class CliState:
|
|
92
|
+
"""Click 根命令共享状态。"""
|
|
93
|
+
|
|
94
|
+
context: AppContext
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def version_callback(
|
|
98
|
+
ctx: click.Context,
|
|
99
|
+
_param: click.Parameter,
|
|
100
|
+
value: bool,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Show version and exit."""
|
|
103
|
+
if not value or ctx.resilient_parsing:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
from bykcli.runtime import build_runtime
|
|
107
|
+
|
|
108
|
+
app_context: AppContext = ctx.obj.context if ctx.obj else build_runtime()
|
|
109
|
+
click.echo(f"v{app_context.version}")
|
|
110
|
+
ctx.exit()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def create_cli() -> click.Group:
|
|
114
|
+
"""创建根 CLI 对象。"""
|
|
115
|
+
|
|
116
|
+
from bykcli.runtime import build_runtime
|
|
117
|
+
|
|
118
|
+
runtime: AppContext | None = None
|
|
119
|
+
|
|
120
|
+
def get_runtime() -> AppContext:
|
|
121
|
+
nonlocal runtime
|
|
122
|
+
if runtime is None:
|
|
123
|
+
runtime = build_runtime()
|
|
124
|
+
return runtime
|
|
125
|
+
|
|
126
|
+
@click.group(
|
|
127
|
+
cls=PluginAwareGroup,
|
|
128
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
129
|
+
add_help_option=False,
|
|
130
|
+
invoke_without_command=True,
|
|
131
|
+
)
|
|
132
|
+
@click.option(
|
|
133
|
+
"--version",
|
|
134
|
+
"-v",
|
|
135
|
+
is_flag=True,
|
|
136
|
+
callback=version_callback,
|
|
137
|
+
expose_value=False,
|
|
138
|
+
is_eager=True,
|
|
139
|
+
help="Show version and exit.",
|
|
140
|
+
)
|
|
141
|
+
@click.pass_context
|
|
142
|
+
def cli(ctx: click.Context) -> None:
|
|
143
|
+
ctx.obj = CliState(context=get_runtime())
|
|
144
|
+
ctx.obj.context.logger.info("BYK CLI v%s started", ctx.obj.context.version)
|
|
145
|
+
if ctx.invoked_subcommand is None:
|
|
146
|
+
from bykcli.infra.view import render_dashboard
|
|
147
|
+
|
|
148
|
+
render_dashboard(ctx.obj.context, cli)
|
|
149
|
+
|
|
150
|
+
cli.runtime_provider = get_runtime
|
|
151
|
+
return cli
|
bykcli/core/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""核心能力模块。"""
|
|
2
|
+
|
|
3
|
+
from bykcli.core.context import AppContext, CommandContextLike
|
|
4
|
+
from bykcli.core.environment import EnvironmentInfo
|
|
5
|
+
from bykcli.core.persistence import PathLayout
|
|
6
|
+
from bykcli.core.state import StateStore
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AppContext",
|
|
10
|
+
"CommandContextLike",
|
|
11
|
+
"EnvironmentInfo",
|
|
12
|
+
"PathLayout",
|
|
13
|
+
"StateStore",
|
|
14
|
+
]
|
bykcli/core/context.py
ADDED
|
@@ -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 bykcli.core.environment import EnvironmentInfo
|
|
10
|
+
from bykcli.core.persistence import PathLayout
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from bykcli.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
|
+
)
|
bykcli/core/errors.py
ADDED
|
@@ -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
|
bykcli/core/plugin.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""插件注册协议。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class PluginProtocol(Protocol):
|
|
12
|
+
"""bykcli 插件协议。
|
|
13
|
+
|
|
14
|
+
就两个成员:
|
|
15
|
+
commands: {子命令名: 描述},用于仪表盘展示
|
|
16
|
+
register: 实例方法,接收 cli,在此添加子命令
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
commands: dict[str, str]
|
|
20
|
+
|
|
21
|
+
def register(self, cli: "click.Group") -> None: ...
|
bykcli/core/state.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""子命令状态存储协议。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StateStore(Protocol):
|
|
10
|
+
"""状态存储协议。"""
|
|
11
|
+
|
|
12
|
+
path: Path
|
|
13
|
+
|
|
14
|
+
def load(self) -> dict[str, Any]: ...
|
|
15
|
+
def save(self, data: dict[str, Any]) -> dict[str, Any]: ...
|
|
16
|
+
def get(self, key: str, default: Any = None) -> Any: ...
|
|
17
|
+
def set(self, key: str, value: Any) -> dict[str, Any]: ...
|
|
18
|
+
def update(self, values: dict[str, Any]) -> dict[str, Any]: ...
|
|
19
|
+
def delete(self, key: str) -> dict[str, Any]: ...
|
|
20
|
+
def clear(self) -> dict[str, Any]: ...
|
bykcli/infra/__init__.py
ADDED
|
File without changes
|
bykcli/infra/cache.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""插件缓存:entry_points 元数据扫描与磁盘缓存。
|
|
2
|
+
|
|
3
|
+
启动时只需一次 IO 读 app.json,plugins 数量不影响启动性能。
|
|
4
|
+
pip install/uninstall 后 site-packages mtime 变化自动触发缓存重建。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import site
|
|
13
|
+
import time
|
|
14
|
+
from importlib.metadata import entry_points
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from bykcli.infra.persistence import read_json, write_json
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("bykcli")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_watched_mtimes() -> dict[str, float]:
|
|
23
|
+
"""收集 site-packages 目录的 mtime,用于缓存失效检测。"""
|
|
24
|
+
mtimes: dict[str, float] = {}
|
|
25
|
+
try:
|
|
26
|
+
paths = site.getsitepackages()
|
|
27
|
+
except Exception:
|
|
28
|
+
return mtimes
|
|
29
|
+
for p in paths:
|
|
30
|
+
if os.path.exists(p):
|
|
31
|
+
try:
|
|
32
|
+
mtimes[p] = os.path.getmtime(p)
|
|
33
|
+
except OSError:
|
|
34
|
+
pass
|
|
35
|
+
return mtimes
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_cache_stale(cached_mtimes: dict[str, float]) -> bool:
|
|
39
|
+
"""对比当前 mtime 与缓存,判断缓存是否失效。"""
|
|
40
|
+
current = get_watched_mtimes()
|
|
41
|
+
return current != cached_mtimes
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scan_plugins() -> dict[str, dict[str, str]]:
|
|
45
|
+
"""扫描 entry_points,import 插件类展开 commands。
|
|
46
|
+
|
|
47
|
+
对每个 entry_point,import 其 Plugin 类,读取 commands dict,
|
|
48
|
+
展开为扁平 {命令名: {module, description}} 结构。
|
|
49
|
+
"""
|
|
50
|
+
commands: dict[str, dict[str, str]] = {}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
plugin_entries = entry_points(group="bykcli.plugins")
|
|
54
|
+
except TypeError:
|
|
55
|
+
plugin_entries = entry_points().get("bykcli.plugins", []) # type: ignore[attr-defined]
|
|
56
|
+
|
|
57
|
+
for ep in plugin_entries:
|
|
58
|
+
try:
|
|
59
|
+
module_name, class_name = ep.value.rsplit(":", 1)
|
|
60
|
+
module = importlib.import_module(module_name)
|
|
61
|
+
plugin_cls = getattr(module, class_name)
|
|
62
|
+
for cmd, desc in plugin_cls.commands.items():
|
|
63
|
+
if cmd in commands:
|
|
64
|
+
logger.warning(
|
|
65
|
+
"command '%s' from %s overrides existing from %s",
|
|
66
|
+
cmd, ep.value, commands[cmd]["module"],
|
|
67
|
+
)
|
|
68
|
+
commands[cmd] = {
|
|
69
|
+
"module": ep.value,
|
|
70
|
+
"description": desc,
|
|
71
|
+
}
|
|
72
|
+
except Exception:
|
|
73
|
+
logger.exception("failed to scan plugin %s", ep.name)
|
|
74
|
+
|
|
75
|
+
return commands
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _build_cache() -> dict:
|
|
79
|
+
"""构建缓存数据结构。"""
|
|
80
|
+
return {
|
|
81
|
+
"watched_mtimes": get_watched_mtimes(),
|
|
82
|
+
"scanned_at": time.time(),
|
|
83
|
+
"commands": scan_plugins(),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_cache(cache_file: Path) -> dict:
|
|
88
|
+
"""读取缓存文件,失效时自动重建。
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
cache_file: ~/.bykcli/cache/app.json 路径
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
缓存数据 dict,保证 commands 键存在
|
|
95
|
+
"""
|
|
96
|
+
data = read_json(cache_file, default=None)
|
|
97
|
+
|
|
98
|
+
if data is None:
|
|
99
|
+
data = _build_cache()
|
|
100
|
+
write_json(cache_file, data)
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
cached_mtimes = data.get("watched_mtimes", {})
|
|
104
|
+
if is_cache_stale(cached_mtimes):
|
|
105
|
+
data = _build_cache()
|
|
106
|
+
write_json(cache_file, data)
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
return data
|
bykcli/infra/logging.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""统一日志初始化。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from bykcli.core.context import AppContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_log_config(context: AppContext) -> dict:
|
|
12
|
+
"""从配置文件读取日志配置。"""
|
|
13
|
+
try:
|
|
14
|
+
logging_config = context.store("byk.config").get("logging", {})
|
|
15
|
+
if isinstance(logging_config, dict):
|
|
16
|
+
return logging_config
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def setup_logging(app_log_file: Path) -> logging.Logger:
|
|
23
|
+
"""初始化应用日志。"""
|
|
24
|
+
logger = logging.getLogger("bykcli")
|
|
25
|
+
if logger.handlers:
|
|
26
|
+
return logger
|
|
27
|
+
|
|
28
|
+
logger.setLevel(logging.INFO)
|
|
29
|
+
handler = logging.FileHandler(app_log_file, encoding="utf-8")
|
|
30
|
+
handler.setFormatter(
|
|
31
|
+
logging.Formatter(
|
|
32
|
+
fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
33
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
logger.addHandler(handler)
|
|
37
|
+
logger.propagate = False
|
|
38
|
+
logger.debug("logger initialized")
|
|
39
|
+
return logger
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_command_logger(context: AppContext, command_name: str) -> logging.Logger:
|
|
43
|
+
"""创建命令专属 logger"""
|
|
44
|
+
logger = logging.getLogger(f"bykcli.{command_name}")
|
|
45
|
+
|
|
46
|
+
if not logger.handlers:
|
|
47
|
+
log_config = _get_log_config(context)
|
|
48
|
+
level_str = log_config.get("level", "INFO").upper()
|
|
49
|
+
separate = log_config.get("separate", True)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
level = getattr(logging, level_str)
|
|
53
|
+
except AttributeError:
|
|
54
|
+
level = logging.INFO
|
|
55
|
+
|
|
56
|
+
logger.setLevel(level)
|
|
57
|
+
|
|
58
|
+
log_file = context.paths.logs_dir / f"{command_name}.log"
|
|
59
|
+
handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
60
|
+
handler.setFormatter(
|
|
61
|
+
logging.Formatter(
|
|
62
|
+
fmt="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
63
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
logger.addHandler(handler)
|
|
67
|
+
|
|
68
|
+
logger.propagate = not separate
|
|
69
|
+
|
|
70
|
+
return logger
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""统一持久化路径与基础文件操作。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from bykcli.core.errors import CliError
|
|
11
|
+
from bykcli.core.persistence import PathLayout
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
def build_path_layout(app_name: str) -> PathLayout:
|
|
16
|
+
"""构建目录布局并确保目录存在。"""
|
|
17
|
+
root_dir = Path.home() / f".{app_name}"
|
|
18
|
+
state_dir = root_dir / "state"
|
|
19
|
+
logs_dir = root_dir / "logs"
|
|
20
|
+
runtime_dir = root_dir / "runtime"
|
|
21
|
+
cache_dir = root_dir / "cache"
|
|
22
|
+
|
|
23
|
+
for directory in (root_dir, state_dir, logs_dir, runtime_dir, cache_dir):
|
|
24
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
return PathLayout(
|
|
27
|
+
root_dir=root_dir,
|
|
28
|
+
state_dir=state_dir,
|
|
29
|
+
logs_dir=logs_dir,
|
|
30
|
+
runtime_dir=runtime_dir,
|
|
31
|
+
app_log_file=logs_dir / "app.log",
|
|
32
|
+
cache_dir=cache_dir,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_json(path: Path, default: T) -> T:
|
|
37
|
+
"""读取 JSON 文件。"""
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
with path.open("r", encoding="utf-8") as file:
|
|
43
|
+
data = json.load(file)
|
|
44
|
+
except json.JSONDecodeError as exc:
|
|
45
|
+
raise CliError(f"Failed to parse JSON file: {path}") from exc
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
raise CliError(f"Unable to read file: {path}") from exc
|
|
48
|
+
|
|
49
|
+
return data if data is not None else default
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_json(path: Path, data: Any) -> None:
|
|
53
|
+
"""以原子方式写入 JSON 文件。"""
|
|
54
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
try:
|
|
56
|
+
with tempfile.NamedTemporaryFile(
|
|
57
|
+
"w",
|
|
58
|
+
delete=False,
|
|
59
|
+
dir=path.parent,
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
) as temp_file:
|
|
62
|
+
json.dump(data, temp_file, indent=2, ensure_ascii=False)
|
|
63
|
+
temp_path = Path(temp_file.name)
|
|
64
|
+
temp_path.replace(path)
|
|
65
|
+
except OSError as exc:
|
|
66
|
+
raise CliError(f"Unable to write file: {path}") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_text(path: Path, default: str | None = None) -> str | None:
|
|
70
|
+
"""读取文本文件。"""
|
|
71
|
+
if not path.exists():
|
|
72
|
+
return default
|
|
73
|
+
try:
|
|
74
|
+
return path.read_text(encoding="utf-8")
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
raise CliError(f"Unable to read file: {path}") from exc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def write_text(path: Path, content: str) -> None:
|
|
80
|
+
"""以原子方式写入文本文件。"""
|
|
81
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
try:
|
|
83
|
+
with tempfile.NamedTemporaryFile(
|
|
84
|
+
"w",
|
|
85
|
+
delete=False,
|
|
86
|
+
dir=path.parent,
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
) as temp_file:
|
|
89
|
+
temp_file.write(content)
|
|
90
|
+
temp_path = Path(temp_file.name)
|
|
91
|
+
temp_path.replace(path)
|
|
92
|
+
except OSError as exc:
|
|
93
|
+
raise CliError(f"Unable to write file: {path}") from exc
|
bykcli/infra/registry.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""插件发现与注册。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("bykcli")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_single_plugin(cli: click.Group, cmd_name: str, module_path: str) -> None:
|
|
14
|
+
"""按需加载单个插件:import → 实例化 → plugin.register(cli)。
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
cli: Click Group 对象
|
|
18
|
+
cmd_name: 命令名(用于日志)
|
|
19
|
+
module_path: "hello.byk:Plugin" 格式的导入路径(包.模块:类名)
|
|
20
|
+
"""
|
|
21
|
+
module_name, class_name = module_path.rsplit(":", 1)
|
|
22
|
+
module = importlib.import_module(module_name)
|
|
23
|
+
plugin_cls = getattr(module, class_name)
|
|
24
|
+
plugin = plugin_cls()
|
|
25
|
+
plugin.register(cli)
|
|
26
|
+
logger.debug("plugin loaded on demand: %s from %s", cmd_name, module_path)
|
bykcli/infra/state.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""状态存储的 JSON 文件实现。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from bykcli.core.state import StateStore
|
|
10
|
+
from bykcli.infra.persistence import read_json, write_json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class JsonStateStore:
|
|
15
|
+
"""基于 JSON 文件的状态存储实现。"""
|
|
16
|
+
|
|
17
|
+
path: Path
|
|
18
|
+
|
|
19
|
+
def load(self) -> dict[str, Any]:
|
|
20
|
+
"""读取完整状态。"""
|
|
21
|
+
data = read_json(self.path, default={})
|
|
22
|
+
return data if isinstance(data, dict) else {}
|
|
23
|
+
|
|
24
|
+
def save(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
25
|
+
"""覆盖保存完整状态。"""
|
|
26
|
+
write_json(self.path, data)
|
|
27
|
+
return data
|
|
28
|
+
|
|
29
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
30
|
+
"""读取单个状态值。"""
|
|
31
|
+
return self.load().get(key, default)
|
|
32
|
+
|
|
33
|
+
def set(self, key: str, value: Any) -> dict[str, Any]:
|
|
34
|
+
"""保存单个状态值。"""
|
|
35
|
+
data = self.load()
|
|
36
|
+
data[key] = value
|
|
37
|
+
return self.save(data)
|
|
38
|
+
|
|
39
|
+
def update(self, values: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
"""批量更新状态。"""
|
|
41
|
+
data = self.load()
|
|
42
|
+
data.update(values)
|
|
43
|
+
return self.save(data)
|
|
44
|
+
|
|
45
|
+
def delete(self, key: str) -> dict[str, Any]:
|
|
46
|
+
"""删除单个状态值。"""
|
|
47
|
+
data = self.load()
|
|
48
|
+
data.pop(key, None)
|
|
49
|
+
return self.save(data)
|
|
50
|
+
|
|
51
|
+
def clear(self) -> dict[str, Any]:
|
|
52
|
+
"""清空当前状态文件。"""
|
|
53
|
+
return self.save({})
|
bykcli/infra/view.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""根命令仪表盘"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from bykcli.core.context import AppContext
|
|
8
|
+
from bykcli.infra.cache import load_cache
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_dashboard(context: AppContext, cli: click.Group) -> None:
|
|
12
|
+
click.echo(cli.get_help(click.Context(cli)))
|
|
13
|
+
|
|
14
|
+
# Commands:从缓存读取(零 import)
|
|
15
|
+
cache_data = load_cache(context.paths.cache_dir / "app.json")
|
|
16
|
+
commands = cache_data.get("commands", {})
|
|
17
|
+
if commands:
|
|
18
|
+
cmd_entries = sorted(
|
|
19
|
+
(name, info.get("description", "") if isinstance(info, dict) else str(info))
|
|
20
|
+
for name, info in commands.items()
|
|
21
|
+
)
|
|
22
|
+
click.echo()
|
|
23
|
+
click.echo("Commands:")
|
|
24
|
+
for name, desc in cmd_entries:
|
|
25
|
+
click.echo(f" ({name}, {desc})")
|
bykcli/runtime.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""运行时装配。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from bykcli import __version__
|
|
8
|
+
from bykcli.core.context import AppContext
|
|
9
|
+
from bykcli.core.environment import collect_environment
|
|
10
|
+
from bykcli.infra.logging import create_command_logger, setup_logging
|
|
11
|
+
from bykcli.infra.persistence import build_path_layout
|
|
12
|
+
from bykcli.infra.state import JsonStateStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _RuntimeAppContext(AppContext):
|
|
16
|
+
"""运行时上下文,带缓存。"""
|
|
17
|
+
|
|
18
|
+
_store_cache: dict[str, JsonStateStore]
|
|
19
|
+
_command_store_cache: dict[tuple[str, str], JsonStateStore]
|
|
20
|
+
|
|
21
|
+
def __init__(self, **kwargs):
|
|
22
|
+
super().__init__(**kwargs)
|
|
23
|
+
self._store_cache = {}
|
|
24
|
+
self._command_store_cache = {}
|
|
25
|
+
|
|
26
|
+
def command_store(self, command_name: str, name: str = "state"):
|
|
27
|
+
key = (command_name, name)
|
|
28
|
+
if key not in self._command_store_cache:
|
|
29
|
+
self._command_store_cache[key] = JsonStateStore(
|
|
30
|
+
path=self.paths.command_state_file(command_name, f"{name}.json"),
|
|
31
|
+
)
|
|
32
|
+
return self._command_store_cache[key]
|
|
33
|
+
|
|
34
|
+
def store(self, name: str = "byk.config"):
|
|
35
|
+
if name not in self._store_cache:
|
|
36
|
+
self._store_cache[name] = JsonStateStore(
|
|
37
|
+
path=self.paths.state_file(name),
|
|
38
|
+
)
|
|
39
|
+
return self._store_cache[name]
|
|
40
|
+
|
|
41
|
+
def get_command_logger(self, command_name: str) -> logging.Logger:
|
|
42
|
+
return create_command_logger(self, command_name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_runtime() -> AppContext:
|
|
46
|
+
"""构建应用运行时。"""
|
|
47
|
+
app_name = "bykcli"
|
|
48
|
+
paths = build_path_layout(app_name=app_name)
|
|
49
|
+
environment = collect_environment(app_name=app_name, version=__version__)
|
|
50
|
+
global_logger = setup_logging(paths.app_log_file)
|
|
51
|
+
|
|
52
|
+
return _RuntimeAppContext(
|
|
53
|
+
app_name=app_name,
|
|
54
|
+
version=__version__,
|
|
55
|
+
paths=paths,
|
|
56
|
+
environment=environment,
|
|
57
|
+
logger=global_logger,
|
|
58
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bykcli-plugin
|
|
3
|
+
Version: 1.0.0a5
|
|
4
|
+
Summary: Plugin infrastructure for bykcli
|
|
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
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: click>=8.0.0
|
|
19
|
+
Requires-Dist: bykcli<2.0,>=1.0.0a5
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest<8.0.0,>=7.0.0; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-cov<5.0.0,>=4.0.0; extra == "test"
|
|
23
|
+
Provides-Extra: build
|
|
24
|
+
Requires-Dist: commitizen; extra == "build"
|
|
25
|
+
Requires-Dist: build; extra == "build"
|
|
26
|
+
Requires-Dist: twine; extra == "build"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# bykcli-plugin
|
|
30
|
+
|
|
31
|
+
[](https://www.python.org/downloads/)
|
|
32
|
+
[](https://github.com/fcbyk/bykcli/actions/workflows/test.yml)
|
|
33
|
+
[](https://codecov.io/gh/fcbyk/bykcli)
|
|
34
|
+
[](https://github.com/fcbyk/bykcli/blob/main/LICENSE)
|
|
35
|
+
|
|
36
|
+
Plugin infrastructure for [bykcli](https://github.com/fcbyk/bykcli).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install bykcli-plugin
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
Implement `PluginProtocol` and register your commands:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# my_plugin.py
|
|
50
|
+
import click
|
|
51
|
+
from bykcli import PluginProtocol
|
|
52
|
+
|
|
53
|
+
class MyPlugin(PluginProtocol):
|
|
54
|
+
commands = {"hello": "say hello"}
|
|
55
|
+
|
|
56
|
+
def register(self, cli: click.Group) -> None:
|
|
57
|
+
@cli.command()
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def hello(ctx):
|
|
60
|
+
click.echo("Hello from my plugin!")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## API
|
|
64
|
+
|
|
65
|
+
- **PluginProtocol** — class-based plugin registration protocol
|
|
66
|
+
- **CommandContext / pass_command_context / get_app_context** — command runtime context
|
|
67
|
+
- **PathItem / PathProvider / register_path_provider** — path management
|
|
68
|
+
- **get_private_networks / ensure_port_available** — network utilities
|
|
69
|
+
- **StateStore** — persistent key-value storage per command
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
bykcli/__init__.py,sha256=xsFL09a1Q8qq4UpJ40zY-KYREG6GQxrarni5fCWIdjw,73
|
|
2
|
+
bykcli/__main__.py,sha256=yuL4pTPoJHbzCRkJUA6ioHiDTNuCXvlcSfvgTUmt4JA,652
|
|
3
|
+
bykcli/app.py,sha256=dDQF6KgPqk5ue0ohb4nTph7zBAacqjF2377gzLBX__M,4476
|
|
4
|
+
bykcli/runtime.py,sha256=jTAUwnqIpQhbvBZeq31i_7eA21xVPVtDxzfRkiBR2w4,1900
|
|
5
|
+
bykcli/api/__init__.py,sha256=9FWuchdejILLQfAiI4X7-ghRCX7QHQq0jSzVi1lYyV0,764
|
|
6
|
+
bykcli/api/context.py,sha256=3oumWF7L4CSzxbRLBFNhEftaI2qNiENVliZuaoT3UPQ,1998
|
|
7
|
+
bykcli/api/network.py,sha256=1HPVhv5Q57bG8dqJiPsqMJrsHFwRPFGWERRixW5jI3g,2787
|
|
8
|
+
bykcli/api/paths.py,sha256=Ia_QpBuNhAvG1TQ5Vb0XL-crNuWxbVRfSRkkids8U5k,857
|
|
9
|
+
bykcli/core/__init__.py,sha256=1Tv1cy39NBGtxvr_pmoGR8nGzTdCxxph47VPMH4JlYg,350
|
|
10
|
+
bykcli/core/context.py,sha256=lSKvzsVznuUZpRJkjqDzCMtTHu1WzxsvC9xhoLTJrZ0,1232
|
|
11
|
+
bykcli/core/environment.py,sha256=6RGzkSEcVHGgo7sMNbljIuJlM615_Gdv8tJr3GLttNI,640
|
|
12
|
+
bykcli/core/errors.py,sha256=3VSTixvdw6rJS0tjMKZQfexkHuv85VxKbaNrrICW4gM,143
|
|
13
|
+
bykcli/core/persistence.py,sha256=YM-AHViBSR5UEdaEscEOQKqDlmltbOFfu02gytYJgz8,1865
|
|
14
|
+
bykcli/core/plugin.py,sha256=ElT1ATpMcIeT-mkGXfOUaOBiYDO1AV1k4d5i-LzlPXk,499
|
|
15
|
+
bykcli/core/state.py,sha256=2lBufRsoLVfHLNgr39ja5FWFalQKuDKNXNE1BI1fsTg,606
|
|
16
|
+
bykcli/infra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
bykcli/infra/cache.py,sha256=87l70F68DmPm51atI5xF6-mKZGbyK7VXH2y1aYRPc4o,3181
|
|
18
|
+
bykcli/infra/logging.py,sha256=djm7HtgUQJGUmYpRVs_z8PQBYc3qyvJ97GSqo8iTDS4,1992
|
|
19
|
+
bykcli/infra/persistence.py,sha256=AtG5oUEgn2eWSeUFNzTWP-B9ZXjRGZApaaRvlcx4RfA,2795
|
|
20
|
+
bykcli/infra/registry.py,sha256=qrCbOqU1Vgj6qrm5yNJ58pM4x6aSaB-hz68lyacr5yE,784
|
|
21
|
+
bykcli/infra/state.py,sha256=MGmpiSXwF_XJsA64xBErytK7jMmrCPrcK78hExD_nls,1514
|
|
22
|
+
bykcli/infra/view.py,sha256=pll_OAGbnLSTHGvJ_ssKjQ52ij7SqYxQ4Lx6QQQHw4Y,774
|
|
23
|
+
bykcli_plugin-1.0.0a5.dist-info/licenses/LICENSE,sha256=b14kwwwpTtEFAEc-R2iWZCxemR8mELElygr36bmD9f4,1061
|
|
24
|
+
bykcli_plugin-1.0.0a5.dist-info/METADATA,sha256=BFWuu6xWLYZsJRbEvNdqHsFPjqiFwBkYSp7Ef6GiiU8,2416
|
|
25
|
+
bykcli_plugin-1.0.0a5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
26
|
+
bykcli_plugin-1.0.0a5.dist-info/top_level.txt,sha256=lgRiXX_EMVcor6xgxcASGY7iOsjF0w67dOWdkPxbGtI,7
|
|
27
|
+
bykcli_plugin-1.0.0a5.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yoki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bykcli
|