gzcli 0.1.0__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.
clihub/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CLIHub - 统一父指令网关,将团队 HTTP 接口注册为子指令。"""
2
+
3
+ __version__ = "0.1.0"
clihub/cli.py ADDED
@@ -0,0 +1,148 @@
1
+ """gzcli 入口:内置 init/update 命令 + 按配置动态生成子指令树。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any, Dict
8
+
9
+ import click
10
+ import httpx
11
+ import yaml
12
+
13
+ from . import __version__
14
+ from . import config as cfg_mod
15
+ from .executor import EXIT_LOCAL_ERROR, EXIT_OK, execute_subcommand
16
+ from .schema import ConfigError, validate_config
17
+
18
+ _ARG_TYPE_MAP = {
19
+ "string": str,
20
+ "int": int,
21
+ "float": float,
22
+ }
23
+
24
+
25
+ def _make_callback(ns_cfg: Dict[str, Any], sub_cfg: Dict[str, Any]):
26
+ def callback(**kwargs):
27
+ code = execute_subcommand(ns_cfg, sub_cfg, kwargs)
28
+ if code:
29
+ raise SystemExit(code)
30
+
31
+ return callback
32
+
33
+
34
+ def _build_subcommand(ns_cfg: Dict[str, Any], sub_name: str, sub_cfg: Dict[str, Any]) -> click.Command:
35
+ params = []
36
+ for flag, spec in (sub_cfg.get("args") or {}).items():
37
+ atype = spec.get("type", "string")
38
+ kwargs: Dict[str, Any] = {
39
+ "required": bool(spec.get("required", False)),
40
+ "default": spec.get("default"),
41
+ "help": spec.get("help", ""),
42
+ }
43
+ if atype == "file":
44
+ kwargs["type"] = click.Path(exists=True, dir_okay=False)
45
+ elif atype == "bool":
46
+ kwargs["is_flag"] = True
47
+ kwargs["default"] = bool(spec.get("default", False))
48
+ kwargs.pop("required")
49
+ else:
50
+ kwargs["type"] = _ARG_TYPE_MAP[atype]
51
+ # 同时接受 --img_url 与 --img-url,参数名统一为下划线形式
52
+ decls = [f"--{flag}"]
53
+ dashed = flag.replace("_", "-")
54
+ if dashed != flag:
55
+ decls.append(f"--{dashed}")
56
+ decls.append(flag.replace("-", "_"))
57
+ params.append(click.Option(decls, **kwargs))
58
+
59
+ return click.Command(
60
+ name=sub_name,
61
+ params=params,
62
+ callback=_make_callback(ns_cfg, sub_cfg),
63
+ help=sub_cfg.get("description", ""),
64
+ )
65
+
66
+
67
+ def _attach_dynamic_commands(root: click.Group, conf: Dict[str, Any]) -> None:
68
+ for ns_name, ns_cfg in conf["commands"].items():
69
+ ns_group = click.Group(name=ns_name, help=ns_cfg.get("description", ""))
70
+ for sub_name, sub_cfg in ns_cfg["subcommands"].items():
71
+ ns_group.add_command(_build_subcommand(ns_cfg, sub_name, sub_cfg))
72
+ root.add_command(ns_group)
73
+
74
+
75
+ @click.command("init")
76
+ @click.option("--conf", "conf_path", required=True,
77
+ type=click.Path(exists=True, dir_okay=False),
78
+ help="子指令注册配置文件(YAML)")
79
+ def init_cmd(conf_path: str) -> None:
80
+ """初始化:校验并注册子指令配置。"""
81
+ try:
82
+ dest = cfg_mod.save_config(Path(conf_path))
83
+ except ConfigError as e:
84
+ click.echo(f"gzcli: {e}", err=True)
85
+ raise SystemExit(EXIT_LOCAL_ERROR)
86
+ conf = cfg_mod.load_config()
87
+ ns_count = len(conf["commands"])
88
+ sub_count = sum(len(ns["subcommands"]) for ns in conf["commands"].values())
89
+ click.echo(f"配置已生效: {dest}")
90
+ click.echo(f"已注册 {ns_count} 个命名空间、{sub_count} 个子指令,执行 gzcli --help 查看")
91
+
92
+
93
+ @click.command("update")
94
+ def update_cmd() -> None:
95
+ """从管理站点拉取最新配置(需配置 registry.url)。"""
96
+ try:
97
+ conf = cfg_mod.load_config()
98
+ except ConfigError as e:
99
+ click.echo(f"gzcli: {e}", err=True)
100
+ raise SystemExit(EXIT_LOCAL_ERROR)
101
+
102
+ url = (conf.get("registry") or {}).get("url")
103
+ if not url:
104
+ click.echo("gzcli: 当前配置未声明 registry.url,无法从管理站点更新", err=True)
105
+ raise SystemExit(EXIT_LOCAL_ERROR)
106
+
107
+ try:
108
+ resp = httpx.get(url, timeout=30)
109
+ resp.raise_for_status()
110
+ data = yaml.safe_load(resp.text)
111
+ validate_config(data)
112
+ except (httpx.HTTPError, yaml.YAMLError, ConfigError) as e:
113
+ click.echo(f"gzcli: 拉取配置失败: {e}", err=True)
114
+ raise SystemExit(EXIT_LOCAL_ERROR)
115
+
116
+ dest = cfg_mod.config_path()
117
+ dest.write_text(resp.text, encoding="utf-8")
118
+ click.echo(f"配置已更新: {dest}")
119
+
120
+
121
+ def _build_root() -> click.Group:
122
+ root = click.Group(
123
+ name="gzcli",
124
+ help="CLIHub 统一入口:团队 HTTP 接口以子指令形式对外提供",
125
+ context_settings={"help_option_names": ["-h", "--help"]},
126
+ )
127
+ root.add_command(init_cmd)
128
+ root.add_command(update_cmd)
129
+
130
+ try:
131
+ conf = cfg_mod.load_config()
132
+ except ConfigError:
133
+ return root # 未初始化时仅暴露 init/update
134
+ _attach_dynamic_commands(root, conf)
135
+ return root
136
+
137
+
138
+ def main() -> None:
139
+ root = _build_root()
140
+ try:
141
+ root(prog_name="gzcli")
142
+ except ConfigError as e:
143
+ click.echo(f"gzcli: {e}", err=True)
144
+ sys.exit(EXIT_LOCAL_ERROR)
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
clihub/config.py ADDED
@@ -0,0 +1,48 @@
1
+ """配置的存储与加载。
2
+
3
+ 配置默认存放在 ``~/.gzcli/config.yaml``,可用环境变量 ``GZCLI_HOME``
4
+ 覆盖配置目录(便于测试与多环境隔离)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ from pathlib import Path
12
+ from typing import Any, Dict
13
+
14
+ import yaml
15
+
16
+ from .schema import ConfigError, validate_config
17
+
18
+
19
+ def config_dir() -> Path:
20
+ return Path(os.environ.get("GZCLI_HOME", Path.home() / ".gzcli"))
21
+
22
+
23
+ def config_path() -> Path:
24
+ return config_dir() / "config.yaml"
25
+
26
+
27
+ def load_config() -> Dict[str, Any]:
28
+ """加载并校验当前生效配置。未初始化或配置损坏时抛 ConfigError。"""
29
+ path = config_path()
30
+ if not path.exists():
31
+ raise ConfigError(
32
+ f"尚未初始化,请先执行 gzcli init --conf <conf.yaml>(期望路径: {path})"
33
+ )
34
+ with open(path, "r", encoding="utf-8") as f:
35
+ data = yaml.safe_load(f)
36
+ return validate_config(data)
37
+
38
+
39
+ def save_config(src: Path) -> Path:
40
+ """将外部配置文件校验后存入配置目录,返回落盘路径。"""
41
+ with open(src, "r", encoding="utf-8") as f:
42
+ data = yaml.safe_load(f)
43
+ validate_config(data)
44
+ d = config_dir()
45
+ d.mkdir(parents=True, exist_ok=True)
46
+ dest = config_path()
47
+ shutil.copyfile(src, dest)
48
+ return dest
clihub/executor.py ADDED
@@ -0,0 +1,174 @@
1
+ """HTTP 调用执行层:把命令行参数按配置映射为一次内部 HTTP 调用。
2
+
3
+ 约定:
4
+ - 正常结果(JSON/文本/流)写 stdout,诊断与错误写 stderr;
5
+ - 非 JSON/文本 的二进制响应落盘,stdout 打印文件路径;
6
+ - 退出码:0 成功;1 接口返回错误或网络错误;2 本地配置/凭证问题。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Optional, Tuple
17
+
18
+ import httpx
19
+
20
+ EXIT_OK = 0
21
+ EXIT_REMOTE_ERROR = 1
22
+ EXIT_LOCAL_ERROR = 2
23
+
24
+
25
+ def _err(msg: str) -> None:
26
+ print(f"gzcli: {msg}", file=sys.stderr)
27
+
28
+
29
+ def _build_auth(auth_cfg: Dict[str, Any]) -> Tuple[Dict[str, str], Optional[httpx.Auth], Optional[str]]:
30
+ """返回 (headers, httpx_auth, error_msg)。凭证缺失时不抛异常,返回错误信息。"""
31
+ atype = (auth_cfg or {}).get("type", "none")
32
+ if atype == "none":
33
+ return {}, None, None
34
+
35
+ if atype in ("header_token", "bearer"):
36
+ env_name = auth_cfg.get("token_env", "")
37
+ token = os.environ.get(env_name, "")
38
+ if not token:
39
+ return {}, None, f"缺少凭证:请设置环境变量 {env_name}"
40
+ if atype == "header_token":
41
+ return {auth_cfg.get("header", "X-API-Token"): token}, None, None
42
+ return {"Authorization": f"Bearer {token}"}, None, None
43
+
44
+ if atype == "basic":
45
+ user = os.environ.get(auth_cfg.get("user_env", ""), "")
46
+ password = os.environ.get(auth_cfg.get("pass_env", ""), "")
47
+ if not user or not password:
48
+ return {}, None, (
49
+ f"缺少凭证:请设置环境变量 {auth_cfg.get('user_env')} / {auth_cfg.get('pass_env')}"
50
+ )
51
+ return {}, httpx.BasicAuth(user, password), None
52
+
53
+ return {}, None, f"不支持的鉴权类型: {atype}"
54
+
55
+
56
+ def _dump_json(data: Any) -> None:
57
+ print(json.dumps(data, ensure_ascii=False, indent=2))
58
+
59
+
60
+ def _handle_response(resp: httpx.Response) -> int:
61
+ ct = resp.headers.get("content-type", "")
62
+ if "json" in ct:
63
+ try:
64
+ _dump_json(resp.json())
65
+ return EXIT_OK
66
+ except ValueError:
67
+ pass
68
+ if ct.startswith("text/") or ct == "":
69
+ sys.stdout.write(resp.text)
70
+ if resp.text and not resp.text.endswith("\n"):
71
+ sys.stdout.write("\n")
72
+ return EXIT_OK
73
+
74
+ # 二进制响应:落盘并打印路径
75
+ ext = ""
76
+ if "/" in ct:
77
+ ext = "." + ct.split("/", 1)[1].split(";")[0].replace("jpeg", "jpg")
78
+ out = Path.cwd() / f"gzcli_output_{int(time.time())}{ext}"
79
+ out.write_bytes(resp.content)
80
+ print(str(out))
81
+ return EXIT_OK
82
+
83
+
84
+ def execute_subcommand(ns_cfg: Dict[str, Any], sub_cfg: Dict[str, Any], values: Dict[str, Any]) -> int:
85
+ """按后端类型分发执行,返回进程退出码。"""
86
+ if sub_cfg.get("type", "http") == "script":
87
+ from .script_backend import execute_script
88
+
89
+ return execute_script(sub_cfg, values)
90
+ return _execute_http(ns_cfg, sub_cfg, values)
91
+
92
+
93
+ def _execute_http(ns_cfg: Dict[str, Any], sub_cfg: Dict[str, Any], values: Dict[str, Any]) -> int:
94
+ """执行 http 型子指令,返回进程退出码。"""
95
+ service = ns_cfg["service"]
96
+ url = service["base_url"].rstrip("/") + sub_cfg["path"]
97
+ method = sub_cfg["method"].upper()
98
+ timeout = float(service.get("timeout", 30))
99
+
100
+ headers, auth, auth_err = _build_auth(service.get("auth") or {})
101
+ if auth_err:
102
+ _err(auth_err)
103
+ return EXIT_LOCAL_ERROR
104
+
105
+ query: Dict[str, Any] = {}
106
+ json_body: Dict[str, Any] = {}
107
+ form: Dict[str, Any] = {}
108
+ files: Dict[str, Any] = {}
109
+ opened = []
110
+
111
+ for flag, spec in (sub_cfg.get("args") or {}).items():
112
+ name = flag.replace("-", "_")
113
+ val = values.get(name)
114
+ if val is None:
115
+ continue
116
+ target, _, field = spec["to"].partition(".")
117
+ atype = spec.get("type", "string")
118
+ try:
119
+ if atype == "file":
120
+ fh = open(val, "rb")
121
+ opened.append(fh)
122
+ files[field] = (Path(val).name, fh)
123
+ elif target == "query":
124
+ query[field] = val
125
+ elif target == "json":
126
+ json_body[field] = val
127
+ elif target == "form":
128
+ form[field] = val if isinstance(val, str) else str(val)
129
+ elif target == "header":
130
+ headers[field] = str(val)
131
+ except OSError as e:
132
+ _err(f"无法读取文件 {val}: {e}")
133
+ for fh in opened:
134
+ fh.close()
135
+ return EXIT_LOCAL_ERROR
136
+
137
+ # 有文件或表单字段时用 multipart,否则 JSON body
138
+ request_kwargs: Dict[str, Any] = {"params": query, "headers": headers, "auth": auth}
139
+ if files or form:
140
+ request_kwargs["data"] = form
141
+ request_kwargs["files"] = files
142
+ elif json_body and method not in ("GET", "HEAD"):
143
+ request_kwargs["json"] = json_body
144
+ elif json_body:
145
+ query.update(json_body)
146
+
147
+ stream = bool(sub_cfg.get("stream"))
148
+ try:
149
+ with httpx.Client(timeout=timeout) as client:
150
+ if stream:
151
+ with client.stream(method, url, **request_kwargs) as resp:
152
+ if resp.status_code >= 400:
153
+ resp.read()
154
+ _err(f"HTTP {resp.status_code}: {resp.text.strip()[:500]}")
155
+ return EXIT_REMOTE_ERROR
156
+ for chunk in resp.iter_bytes():
157
+ sys.stdout.buffer.write(chunk)
158
+ sys.stdout.buffer.flush()
159
+ return EXIT_OK
160
+
161
+ resp = client.request(method, url, **request_kwargs)
162
+ if resp.status_code >= 400:
163
+ _err(f"HTTP {resp.status_code}: {resp.text.strip()[:500]}")
164
+ return EXIT_REMOTE_ERROR
165
+ return _handle_response(resp)
166
+ except httpx.TimeoutException:
167
+ _err(f"请求超时({timeout}s): {url}")
168
+ return EXIT_REMOTE_ERROR
169
+ except httpx.HTTPError as e:
170
+ _err(f"网络错误: {e}")
171
+ return EXIT_REMOTE_ERROR
172
+ finally:
173
+ for fh in opened:
174
+ fh.close()
clihub/schema.py ADDED
@@ -0,0 +1,161 @@
1
+ """conf.yaml 的 Schema 校验。
2
+
3
+ 配置结构::
4
+
5
+ version: 1
6
+ registry: # 可选,未来 Web 管理站点
7
+ url: https://clihub.example.com/api/config
8
+ commands: # 一级子指令(团队命名空间)
9
+ img_detect:
10
+ description: 图像检测能力集
11
+ service: # http 型子指令必填
12
+ base_url: http://img-detect.internal.api
13
+ timeout: 30
14
+ auth:
15
+ type: header_token # header_token | bearer | basic | none
16
+ header: X-API-Token
17
+ token_env: GZCLI_IMG_TOKEN
18
+ subcommands: # 二级子指令
19
+ obj_detect:
20
+ description: 图像目标检测
21
+ method: POST # http 型必填
22
+ path: /v1/detect # http 型必填
23
+ stream: false
24
+ args:
25
+ img:
26
+ type: file # string|int|float|bool|file
27
+ to: form.img # http 目标: query|json|form|header.字段名
28
+ required: true
29
+
30
+ data_team: # script 型命名空间可以没有 service
31
+ description: 数据团队工具集
32
+ subcommands:
33
+ clean_csv:
34
+ type: script # script 后端:执行团队提供的 Python 脚本
35
+ description: CSV 清洗工具
36
+ script:
37
+ source: git+https://git.internal.com/data-team/clean_csv.git@v1.2.0
38
+ # 也支持本地路径: ./tools/clean_csv.py(文件)或 ./tools/clean_csv/(目录)
39
+ entry: clean_csv.py # source 为目录时必填;为 .py 文件时忽略
40
+ timeout: 300 # 可选,秒;缺省不限制
41
+ args:
42
+ input:
43
+ type: file
44
+ to: argv.input # script 目标: argv.参数名 | env.环境变量名
45
+ required: true
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import re
51
+ from typing import Any, Dict
52
+
53
+ SUPPORTED_ARG_TYPES = {"string", "int", "float", "bool", "file"}
54
+ SUPPORTED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"}
55
+ SUPPORTED_AUTH = {"header_token", "bearer", "basic", "none"}
56
+ SUPPORTED_BACKENDS = {"http", "script"}
57
+
58
+ _HTTP_TO_RE = re.compile(r"^(query|json|form|header)\.[A-Za-z0-9_.\[\]-]+$")
59
+ _SCRIPT_TO_RE = re.compile(r"^(argv|env)\.[A-Za-z_][A-Za-z0-9_]*$")
60
+
61
+
62
+ class ConfigError(Exception):
63
+ """配置不合法或缺失。"""
64
+
65
+
66
+ def _fail(path: str, msg: str) -> None:
67
+ raise ConfigError(f"配置错误 [{path}]: {msg}")
68
+
69
+
70
+ def _validate_auth(auth: Any, p: str) -> None:
71
+ if auth.get("type") not in SUPPORTED_AUTH:
72
+ _fail(f"{p}.type", f"仅支持 {sorted(SUPPORTED_AUTH)}")
73
+ if auth.get("type") in ("header_token", "bearer") and not auth.get("token_env"):
74
+ _fail(f"{p}.token_env", "header_token/bearer 必须指定读取凭证的环境变量名")
75
+ if auth.get("type") == "basic" and not (auth.get("user_env") and auth.get("pass_env")):
76
+ _fail(f"{p}.user_env/pass_env", "basic 必须指定用户名/密码的环境变量名")
77
+
78
+
79
+ def _validate_http_sub(sub: Dict[str, Any], sp: str) -> None:
80
+ method = str(sub.get("method", "")).upper()
81
+ if method not in SUPPORTED_METHODS:
82
+ _fail(f"{sp}.method", f"http 型子指令必填,仅支持 {sorted(SUPPORTED_METHODS)}")
83
+ path = sub.get("path")
84
+ if not isinstance(path, str) or not path.startswith("/"):
85
+ _fail(f"{sp}.path", "http 型子指令必填,且必须以 / 开头")
86
+
87
+
88
+ def _validate_script_sub(sub: Dict[str, Any], sp: str) -> None:
89
+ script = sub.get("script")
90
+ if not isinstance(script, dict) or not script.get("source"):
91
+ _fail(f"{sp}.script.source", "script 型子指令必填(本地路径或 git+URL[@ref])")
92
+ source = str(script["source"])
93
+ if not (source.startswith("git+") or source.endswith(".py") or script.get("entry")):
94
+ _fail(f"{sp}.script.entry", "source 为目录时必须指定 entry 入口文件")
95
+
96
+
97
+ def _validate_args(sub: Dict[str, Any], sp: str, backend: str) -> None:
98
+ args = sub.get("args") or {}
99
+ if not isinstance(args, dict):
100
+ _fail(f"{sp}.args", "必须是 mapping")
101
+ to_re = _HTTP_TO_RE if backend == "http" else _SCRIPT_TO_RE
102
+ to_hint = "query|json|form|header.字段名" if backend == "http" else "argv.参数名 | env.环境变量名"
103
+ for flag, spec in args.items():
104
+ ap = f"{sp}.args.{flag}"
105
+ if not isinstance(spec, dict):
106
+ _fail(ap, "必须是 mapping")
107
+ atype = spec.get("type", "string")
108
+ if atype not in SUPPORTED_ARG_TYPES:
109
+ _fail(f"{ap}.type", f"仅支持 {sorted(SUPPORTED_ARG_TYPES)}")
110
+ to = spec.get("to")
111
+ if not isinstance(to, str) or not to_re.match(to):
112
+ _fail(f"{ap}.to", f"必填,格式为 {to_hint}")
113
+ if backend == "http" and atype == "file" and not to.startswith("form."):
114
+ _fail(f"{ap}.to", "http 后端中 file 类型只能映射到 form.字段(multipart 上传)")
115
+ if backend == "script" and atype == "file" and not to.startswith("argv."):
116
+ _fail(f"{ap}.to", "script 后端中 file 类型只能映射到 argv.参数(传文件路径)")
117
+
118
+
119
+ def validate_config(data: Any) -> Dict[str, Any]:
120
+ """校验并返回配置 dict,不合法时抛 ConfigError。"""
121
+ if not isinstance(data, dict):
122
+ _fail("$", "顶层必须是 mapping")
123
+
124
+ commands = data.get("commands")
125
+ if not isinstance(commands, dict) or not commands:
126
+ _fail("commands", "至少需要一个一级子指令(团队命名空间)")
127
+
128
+ for ns_name, ns in commands.items():
129
+ p = f"commands.{ns_name}"
130
+ if not isinstance(ns, dict):
131
+ _fail(p, "必须是 mapping")
132
+
133
+ subs = ns.get("subcommands")
134
+ if not isinstance(subs, dict) or not subs:
135
+ _fail(f"{p}.subcommands", "至少需要一个二级子指令")
136
+
137
+ backends = set()
138
+ for sub_name, sub in subs.items():
139
+ sp = f"{p}.subcommands.{sub_name}"
140
+ if not isinstance(sub, dict):
141
+ _fail(sp, "必须是 mapping")
142
+ backend = sub.get("type", "http")
143
+ if backend not in SUPPORTED_BACKENDS:
144
+ _fail(f"{sp}.type", f"仅支持 {sorted(SUPPORTED_BACKENDS)},缺省为 http")
145
+ backends.add(backend)
146
+ if backend == "http":
147
+ _validate_http_sub(sub, sp)
148
+ else:
149
+ _validate_script_sub(sub, sp)
150
+ _validate_args(sub, sp, backend)
151
+
152
+ # 含 http 子指令时必须有 service;纯 script 命名空间可以没有
153
+ service = ns.get("service")
154
+ if service is None and "http" in backends:
155
+ _fail(f"{p}.service.base_url", "命名空间包含 http 型子指令,必填")
156
+ if service is not None:
157
+ if not service.get("base_url"):
158
+ _fail(f"{p}.service.base_url", "必填,团队接口的 base url")
159
+ _validate_auth(service.get("auth") or {"type": "none"}, f"{p}.service.auth")
160
+
161
+ return data
@@ -0,0 +1,132 @@
1
+ """script 后端:把子指令映射为对团队 Python 脚本的子进程调用。
2
+
3
+ - source 支持本地路径(.py 文件或目录)与 git+URL[@ref](缓存到
4
+ ``$GZCLI_HOME/tools/``,ref 建议锁定 tag,保证内容可追溯);
5
+ - 参数映射:``argv.xxx`` 拼为 ``--xxx 值``(bool 为开关、file 传绝对路径),
6
+ ``env.XXX`` 注入子进程环境变量;
7
+ - 依赖隔离:脚本目录含 pyproject.toml / requirements.txt 且系统有 uv 时,
8
+ 用 ``uv run`` 自动建隔离环境;否则回退到当前解释器直接执行;
9
+ - stdout/stderr 原样透传,退出码透传。
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import os
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional, Tuple
21
+
22
+ from . import config as cfg_mod
23
+
24
+ EXIT_LOCAL_ERROR = 2
25
+
26
+
27
+ def _err(msg: str) -> None:
28
+ print(f"gzcli: {msg}", file=sys.stderr)
29
+
30
+
31
+ def _tools_dir() -> Path:
32
+ d = cfg_mod.config_dir() / "tools"
33
+ d.mkdir(parents=True, exist_ok=True)
34
+ return d
35
+
36
+
37
+ def _resolve_source(script_cfg: Dict[str, Any]) -> Tuple[Optional[Path], Optional[str]]:
38
+ """返回 (脚本文件绝对路径, 错误信息)。"""
39
+ source = str(script_cfg["source"])
40
+
41
+ if source.startswith("git+"):
42
+ url_ref = source[4:]
43
+ url, _, ref = url_ref.rpartition("@")
44
+ key = hashlib.sha256(url_ref.encode()).hexdigest()[:12]
45
+ dest = _tools_dir() / key
46
+ if not dest.exists():
47
+ cmd = ["git", "clone", "--depth", "1"]
48
+ if ref:
49
+ cmd += ["--branch", ref]
50
+ cmd += [url, str(dest)]
51
+ _err(f"首次使用,正在拉取脚本: {url_ref}")
52
+ proc = subprocess.run(cmd, capture_output=True, text=True)
53
+ if proc.returncode != 0:
54
+ return None, f"拉取脚本失败: {proc.stderr.strip()[:300]}"
55
+ base = dest
56
+ else:
57
+ base = Path(source).expanduser().resolve()
58
+ if not base.exists():
59
+ return None, f"脚本路径不存在: {base}"
60
+
61
+ if base.is_file():
62
+ return base, None
63
+ entry = script_cfg.get("entry")
64
+ if not entry:
65
+ return None, "source 为目录时必须在配置中指定 script.entry"
66
+ script_path = base / entry
67
+ if not script_path.is_file():
68
+ return None, f"入口文件不存在: {script_path}"
69
+ return script_path, None
70
+
71
+
72
+ def _runner_for(script_path: Path) -> List[str]:
73
+ """优先用 uv 建隔离环境运行;否则回退当前解释器。"""
74
+ d = script_path.parent
75
+ uv = shutil.which("uv")
76
+ if uv:
77
+ if (d / "pyproject.toml").exists():
78
+ return [uv, "run", "--project", str(d), "python", str(script_path)]
79
+ if (d / "requirements.txt").exists():
80
+ return [uv, "run", "--with-requirements", str(d / "requirements.txt"),
81
+ "python", str(script_path)]
82
+ return [sys.executable, str(script_path)]
83
+
84
+
85
+ def _build_argv_and_env(
86
+ args_spec: Dict[str, Any], values: Dict[str, Any]
87
+ ) -> Tuple[List[str], Dict[str, str]]:
88
+ argv: List[str] = []
89
+ env = dict(os.environ)
90
+ for flag, spec in args_spec.items():
91
+ name = flag.replace("-", "_")
92
+ val = values.get(name)
93
+ if val is None:
94
+ continue
95
+ target, _, field = spec["to"].partition(".")
96
+ if target == "env":
97
+ env[field] = str(val)
98
+ continue
99
+ # argv 目标
100
+ dashed = field.replace("_", "-")
101
+ atype = spec.get("type", "string")
102
+ if atype == "bool":
103
+ if val:
104
+ argv.append(f"--{dashed}")
105
+ elif atype == "file":
106
+ argv += [f"--{dashed}", str(Path(val).resolve())]
107
+ else:
108
+ argv += [f"--{dashed}", str(val)]
109
+ return argv, env
110
+
111
+
112
+ def execute_script(sub_cfg: Dict[str, Any], values: Dict[str, Any]) -> int:
113
+ """执行 script 型子指令,返回脚本退出码(本地问题返回 2)。"""
114
+ script_cfg = sub_cfg["script"]
115
+ script_path, err = _resolve_source(script_cfg)
116
+ if err:
117
+ _err(err)
118
+ return EXIT_LOCAL_ERROR
119
+
120
+ argv, env = _build_argv_and_env(sub_cfg.get("args") or {}, values)
121
+ cmd = _runner_for(script_path) + argv
122
+ timeout = script_cfg.get("timeout") # 缺省不限制
123
+
124
+ try:
125
+ proc = subprocess.run(cmd, cwd=str(script_path.parent), env=env, timeout=timeout)
126
+ return proc.returncode
127
+ except subprocess.TimeoutExpired:
128
+ _err(f"脚本执行超时({timeout}s): {script_path.name}")
129
+ return EXIT_LOCAL_ERROR
130
+ except OSError as e:
131
+ _err(f"脚本启动失败: {e}")
132
+ return EXIT_LOCAL_ERROR
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: gzcli
3
+ Version: 0.1.0
4
+ Summary: CLIHub: 一个父指令统一网关,将团队 HTTP 接口以子指令方式对外提供
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: click>=8.1
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: pyyaml>=6.0
12
+ Dynamic: license-file
13
+
14
+ # gzcli
15
+
16
+ 一个父指令统一网关:各团队将 HTTP 接口通过配置文件注册为 `gzcli` 的子指令,
17
+ 鉴权、调用、更新等横切逻辑由 `gzcli` 统一屏蔽。
18
+
19
+ ```bash
20
+ gzcli init --conf conf.yaml # 注册子指令配置
21
+ gzcli img_detect detect --img a.jpg # 调用团队接口
22
+ ```
23
+
24
+ 详细设计见项目内讨论,MVP 阶段配置 Schema 见 `examples/conf.yaml`。
@@ -0,0 +1,12 @@
1
+ clihub/__init__.py,sha256=4TJJzwbeMEIoi6vZMpVVOGHOpdY7b0Jt1rlMdol0N7E,105
2
+ clihub/cli.py,sha256=iVjQyewPUtp_0u-Fhi_1PQG9Yll92RlA980cccC-3A4,4795
3
+ clihub/config.py,sha256=xsYsR-YDTjKCUvOr34OPE_wLYrJV77F7sE_MEg2qXaM,1323
4
+ clihub/executor.py,sha256=Up-_ZPAKVBH-NIfu-ztuBx2tiT0nXAVROW0unvq-Ro4,6170
5
+ clihub/schema.py,sha256=1E-NhL_xzfchG4A8wDjHKOafyft1H_ONibjGzIxuzL0,7107
6
+ clihub/script_backend.py,sha256=E0PyWxU4sTYbJf5u1K9WG2_xhPw75VrM4zkTIxFhibo,4625
7
+ gzcli-0.1.0.dist-info/licenses/LICENSE,sha256=kzoRrmT5XQUVc530O_UppnrVVMJ35bX3cFnleBqXKxc,1071
8
+ gzcli-0.1.0.dist-info/METADATA,sha256=ZGn0zTwbGtbsshPcyh6fF_8mDQQhguukYLewSA_ASzA,750
9
+ gzcli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ gzcli-0.1.0.dist-info/entry_points.txt,sha256=BiTHtDw1Zyo9QO4ymkby2TJ41we2EEGlHihjlm32eaA,42
11
+ gzcli-0.1.0.dist-info/top_level.txt,sha256=nP-CtByVbnzFe8b_po_GdbU3x3zeKMbAFTmL6hZ9BDs,7
12
+ gzcli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gzcli = clihub.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CLIHub Authors
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
+ clihub