funchub-sdk 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.
- funchub/__init__.py +3 -0
- funchub/cli.py +204 -0
- funchub/client.py +224 -0
- funchub/decorators.py +84 -0
- funchub/exceptions.py +42 -0
- funchub/github_client.py +244 -0
- funchub/loader.py +151 -0
- funchub/models.py +33 -0
- funchub/version_parser.py +99 -0
- funchub_sdk-0.1.0.dist-info/METADATA +187 -0
- funchub_sdk-0.1.0.dist-info/RECORD +14 -0
- funchub_sdk-0.1.0.dist-info/WHEEL +5 -0
- funchub_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- funchub_sdk-0.1.0.dist-info/top_level.txt +1 -0
funchub/__init__.py
ADDED
funchub/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from funchub.client import FuncHub
|
|
9
|
+
from funchub.exceptions import ConflictError, FuncHubError, ToolNotFoundError, VersionNotFoundError
|
|
10
|
+
from funchub.github_client import load_config, save_config
|
|
11
|
+
from funchub.models import ToolDefinition, ToolVersion
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.option("--registry", "-r", envvar="FUNCHUB_REGISTRY", help="中央索引 URL")
|
|
16
|
+
@click.option("--token", envvar="GITHUB_TOKEN", help="GitHub PAT")
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def cli(ctx: click.Context, registry: Optional[str], token: Optional[str]) -> None:
|
|
19
|
+
ctx.ensure_object(dict)
|
|
20
|
+
ctx.obj["hub"] = FuncHub(registry=registry, token=token)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@cli.command()
|
|
24
|
+
@click.option("--token", required=True, help="GitHub Personal Access Token")
|
|
25
|
+
def login(token: str) -> None:
|
|
26
|
+
cfg = load_config()
|
|
27
|
+
cfg["github_token"] = token
|
|
28
|
+
save_config(cfg)
|
|
29
|
+
click.echo("GitHub Token 已保存到 ~/.funchub/config.yaml")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@cli.command()
|
|
33
|
+
@click.argument("key")
|
|
34
|
+
@click.argument("value")
|
|
35
|
+
def config(key: str, value: str) -> None:
|
|
36
|
+
cfg = load_config()
|
|
37
|
+
cfg[key] = value
|
|
38
|
+
save_config(cfg)
|
|
39
|
+
click.echo(f"配置项 {key} 已设置为 {value}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@cli.command()
|
|
43
|
+
@click.option("--version", "ver", required=True, help="发布的版本号")
|
|
44
|
+
@click.option("--force", is_flag=True, help="覆盖同名工具")
|
|
45
|
+
@click.option("--dry-run", is_flag=True, help="预览不实际提交")
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def publish(ctx: click.Context, ver: str, force: bool, dry_run: bool) -> None:
|
|
48
|
+
hub: FuncHub = ctx.obj["hub"]
|
|
49
|
+
tool_file = Path("funchub-tool.yaml")
|
|
50
|
+
if not tool_file.exists():
|
|
51
|
+
click.echo("错误: 当前目录未找到 funchub-tool.yaml", err=True)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
raw = tool_file.read_text(encoding="utf-8")
|
|
54
|
+
data = yaml.safe_load(raw)
|
|
55
|
+
is_prerelease = any(tag in ver.lower() for tag in ("alpha", "beta", "rc", "pre"))
|
|
56
|
+
tv = ToolVersion(
|
|
57
|
+
version=ver,
|
|
58
|
+
source_repo=data.get("source_repo", ""),
|
|
59
|
+
source_ref=data.get("source_ref", f"v{ver}"),
|
|
60
|
+
dependencies=data.get("dependencies", []),
|
|
61
|
+
is_prerelease=is_prerelease,
|
|
62
|
+
)
|
|
63
|
+
tool_def = ToolDefinition(
|
|
64
|
+
name=data["name"],
|
|
65
|
+
description=data.get("description", ""),
|
|
66
|
+
parameters=data.get("parameters", {"type": "object", "properties": {}}),
|
|
67
|
+
author=data.get("author", "anonymous"),
|
|
68
|
+
entry_point=data.get("entry_point", "index:main"),
|
|
69
|
+
versions=[tv],
|
|
70
|
+
)
|
|
71
|
+
try:
|
|
72
|
+
result = hub.publish(tool_def, force=force, dry_run=dry_run)
|
|
73
|
+
if dry_run:
|
|
74
|
+
click.echo(f"[DRY RUN] {result}")
|
|
75
|
+
else:
|
|
76
|
+
click.echo(f"发布成功: {result}")
|
|
77
|
+
except ConflictError as e:
|
|
78
|
+
click.echo(f"错误: {e}", err=True)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
except FuncHubError as e:
|
|
81
|
+
click.echo(f"错误: {e}", err=True)
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@cli.command()
|
|
86
|
+
@click.argument("query")
|
|
87
|
+
@click.pass_context
|
|
88
|
+
def search(ctx: click.Context, query: str) -> None:
|
|
89
|
+
hub: FuncHub = ctx.obj["hub"]
|
|
90
|
+
results = hub.search(query)
|
|
91
|
+
if not results:
|
|
92
|
+
click.echo("未找到匹配的工具")
|
|
93
|
+
return
|
|
94
|
+
for t in results:
|
|
95
|
+
click.echo(f"{t.name} - {t.description}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@cli.command()
|
|
99
|
+
@click.argument("tool_spec")
|
|
100
|
+
@click.option("--prerelease", is_flag=True, help="包含预发布版本")
|
|
101
|
+
@click.option("--yes", is_flag=True, help="跳过安全确认")
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def install(ctx: click.Context, tool_spec: str, prerelease: bool, yes: bool) -> None:
|
|
104
|
+
hub: FuncHub = ctx.obj["hub"]
|
|
105
|
+
constraint: Optional[str] = None
|
|
106
|
+
tool_name = tool_spec
|
|
107
|
+
if "@" in tool_spec:
|
|
108
|
+
tool_name, constraint = tool_spec.split("@", 1)
|
|
109
|
+
try:
|
|
110
|
+
hub.install(
|
|
111
|
+
tool_name,
|
|
112
|
+
constraint=constraint,
|
|
113
|
+
include_prerelease=prerelease,
|
|
114
|
+
yes=yes,
|
|
115
|
+
)
|
|
116
|
+
click.echo(f"✅ 工具 {tool_name} 安装成功")
|
|
117
|
+
except (ToolNotFoundError, VersionNotFoundError) as e:
|
|
118
|
+
click.echo(f"错误: {e}", err=True)
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
except FuncHubError as e:
|
|
121
|
+
click.echo(f"错误: {e}", err=True)
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.command("list")
|
|
126
|
+
@click.pass_context
|
|
127
|
+
def list_tools(ctx: click.Context) -> None:
|
|
128
|
+
hub: FuncHub = ctx.obj["hub"]
|
|
129
|
+
items = hub.list_installed()
|
|
130
|
+
if not items:
|
|
131
|
+
click.echo("未安装任何工具")
|
|
132
|
+
return
|
|
133
|
+
for item in items:
|
|
134
|
+
click.echo(f"{item['name']}@{item['version']} ({item['source_repo']})")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@cli.command()
|
|
138
|
+
@click.argument("name", required=False)
|
|
139
|
+
@click.option("--all", "update_all", is_flag=True, help="更新所有工具")
|
|
140
|
+
@click.option("--prerelease", is_flag=True, help="包含预发布版本")
|
|
141
|
+
@click.option("--yes", is_flag=True, help="跳过安全确认")
|
|
142
|
+
@click.pass_context
|
|
143
|
+
def update(
|
|
144
|
+
ctx: click.Context,
|
|
145
|
+
name: Optional[str],
|
|
146
|
+
update_all: bool,
|
|
147
|
+
prerelease: bool,
|
|
148
|
+
yes: bool,
|
|
149
|
+
) -> None:
|
|
150
|
+
hub: FuncHub = ctx.obj["hub"]
|
|
151
|
+
if update_all:
|
|
152
|
+
results = hub.update_all(include_prerelease=prerelease, yes=yes)
|
|
153
|
+
if not results:
|
|
154
|
+
click.echo("所有工具已是最新")
|
|
155
|
+
for r in results:
|
|
156
|
+
click.echo(r)
|
|
157
|
+
return
|
|
158
|
+
if not name:
|
|
159
|
+
click.echo("请指定工具名称或使用 --all", err=True)
|
|
160
|
+
sys.exit(1)
|
|
161
|
+
try:
|
|
162
|
+
result = hub.update(name, include_prerelease=prerelease, yes=yes)
|
|
163
|
+
click.echo(f"✅ 工具 {name} 已更新到 {result}")
|
|
164
|
+
except FuncHubError as e:
|
|
165
|
+
click.echo(f"错误: {e}", err=True)
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@cli.command()
|
|
170
|
+
@click.argument("name")
|
|
171
|
+
@click.pass_context
|
|
172
|
+
def info(ctx: click.Context, name: str) -> None:
|
|
173
|
+
hub: FuncHub = ctx.obj["hub"]
|
|
174
|
+
tool_def = hub.info(name)
|
|
175
|
+
if tool_def is None:
|
|
176
|
+
click.echo(f"工具 '{name}' 未找到")
|
|
177
|
+
return
|
|
178
|
+
click.echo(f"名称: {tool_def.name}")
|
|
179
|
+
click.echo(f"描述: {tool_def.description}")
|
|
180
|
+
click.echo(f"作者: {tool_def.author}")
|
|
181
|
+
click.echo(f"入口: {tool_def.entry_point}")
|
|
182
|
+
click.echo("版本:")
|
|
183
|
+
for v in tool_def.versions:
|
|
184
|
+
pre = " (预发布)" if v.is_prerelease else ""
|
|
185
|
+
click.echo(f" - {v.version}{pre}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cli.command()
|
|
189
|
+
@click.argument("name")
|
|
190
|
+
@click.pass_context
|
|
191
|
+
def uninstall(ctx: click.Context, name: str) -> None:
|
|
192
|
+
hub: FuncHub = ctx.obj["hub"]
|
|
193
|
+
if hub.uninstall(name):
|
|
194
|
+
click.echo(f"✅ 工具 {name} 已卸载")
|
|
195
|
+
else:
|
|
196
|
+
click.echo(f"工具 {name} 未安装")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> None:
|
|
200
|
+
cli()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == "__main__":
|
|
204
|
+
main()
|
funchub/client.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from funchub.exceptions import (
|
|
8
|
+
FuncHubError,
|
|
9
|
+
ToolNotFoundError,
|
|
10
|
+
VersionNotFoundError,
|
|
11
|
+
)
|
|
12
|
+
from funchub.github_client import (
|
|
13
|
+
GitHubRegistryClient,
|
|
14
|
+
load_config,
|
|
15
|
+
resolve_registry,
|
|
16
|
+
resolve_token,
|
|
17
|
+
retry_request,
|
|
18
|
+
)
|
|
19
|
+
from funchub.loader import Loader
|
|
20
|
+
from funchub.models import ToolDefinition, ToolVersion
|
|
21
|
+
from funchub.version_parser import resolve_version
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FuncHub:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
registry: Optional[str] = None,
|
|
28
|
+
token: Optional[str] = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._registry_url = resolve_registry(registry)
|
|
31
|
+
self._token = resolve_token(token)
|
|
32
|
+
self._cache_base = Path.home() / ".funchub" / "cache"
|
|
33
|
+
self._registry_cache_path = Path.home() / ".funchub" / "registry_cache.json"
|
|
34
|
+
self._funcs: Dict[str, Callable] = {}
|
|
35
|
+
|
|
36
|
+
def _fetch_registry(
|
|
37
|
+
self, use_cache: bool = True
|
|
38
|
+
) -> Dict[str, ToolDefinition]:
|
|
39
|
+
now = time.time()
|
|
40
|
+
if use_cache and self._registry_cache_path.exists():
|
|
41
|
+
raw = self._registry_cache_path.read_text(encoding="utf-8")
|
|
42
|
+
try:
|
|
43
|
+
cached = json.loads(raw)
|
|
44
|
+
cache_time = cached.get("_cached_at", 0)
|
|
45
|
+
if now - cache_time < 300:
|
|
46
|
+
tools_raw = cached.get("tools", {})
|
|
47
|
+
return {
|
|
48
|
+
k: ToolDefinition(**v)
|
|
49
|
+
for k, v in tools_raw.items()
|
|
50
|
+
}
|
|
51
|
+
except (json.JSONDecodeError, TypeError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
resp = retry_request("GET", self._registry_url)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
data = resp.json()
|
|
57
|
+
|
|
58
|
+
tools_raw: Dict[str, Any] = {}
|
|
59
|
+
if "tools" in data:
|
|
60
|
+
tools_raw = data["tools"]
|
|
61
|
+
else:
|
|
62
|
+
tools_raw = data
|
|
63
|
+
|
|
64
|
+
tools: Dict[str, ToolDefinition] = {}
|
|
65
|
+
for k, v in tools_raw.items():
|
|
66
|
+
if isinstance(v, dict):
|
|
67
|
+
tools[k] = ToolDefinition(**v)
|
|
68
|
+
|
|
69
|
+
cache_data = {
|
|
70
|
+
"_cached_at": now,
|
|
71
|
+
"tools": {k: v.model_dump() for k, v in tools.items()},
|
|
72
|
+
}
|
|
73
|
+
self._registry_cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
self._registry_cache_path.write_text(
|
|
75
|
+
json.dumps(cache_data, indent=2), encoding="utf-8"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return tools
|
|
79
|
+
|
|
80
|
+
def search(self, query: str) -> List[ToolDefinition]:
|
|
81
|
+
registry = self._fetch_registry()
|
|
82
|
+
q = query.lower()
|
|
83
|
+
results = []
|
|
84
|
+
for tool_def in registry.values():
|
|
85
|
+
if q in tool_def.name.lower() or q in tool_def.description.lower():
|
|
86
|
+
results.append(tool_def)
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
def get_tool(self, name: str) -> Optional[ToolDefinition]:
|
|
90
|
+
registry = self._fetch_registry()
|
|
91
|
+
return registry.get(name)
|
|
92
|
+
|
|
93
|
+
def install(
|
|
94
|
+
self,
|
|
95
|
+
tool_name: str,
|
|
96
|
+
constraint: Optional[str] = None,
|
|
97
|
+
include_prerelease: bool = False,
|
|
98
|
+
yes: bool = False,
|
|
99
|
+
) -> Callable:
|
|
100
|
+
if "@" in tool_name and constraint is None:
|
|
101
|
+
parts = tool_name.split("@", 1)
|
|
102
|
+
tool_name = parts[0]
|
|
103
|
+
constraint = parts[1]
|
|
104
|
+
registry = self._fetch_registry()
|
|
105
|
+
tool_def = registry.get(tool_name)
|
|
106
|
+
if tool_def is None:
|
|
107
|
+
raise ToolNotFoundError(tool_name)
|
|
108
|
+
|
|
109
|
+
available = [v.version for v in tool_def.versions]
|
|
110
|
+
target_version = resolve_version(
|
|
111
|
+
available, constraint or "latest", include_prerelease=include_prerelease
|
|
112
|
+
)
|
|
113
|
+
if target_version is None:
|
|
114
|
+
raise VersionNotFoundError(constraint or "latest")
|
|
115
|
+
|
|
116
|
+
cache_dir = self._cache_base / tool_name
|
|
117
|
+
version_file = cache_dir / ".version"
|
|
118
|
+
|
|
119
|
+
if version_file.exists():
|
|
120
|
+
cached_version = version_file.read_text(encoding="utf-8").strip()
|
|
121
|
+
if cached_version == target_version:
|
|
122
|
+
func = self._funcs.get(tool_name)
|
|
123
|
+
if func is not None:
|
|
124
|
+
return func
|
|
125
|
+
|
|
126
|
+
func = Loader.load_function(
|
|
127
|
+
tool_def, target_version, self._cache_base, yes=yes
|
|
128
|
+
)
|
|
129
|
+
self._funcs[tool_name] = func
|
|
130
|
+
return func
|
|
131
|
+
|
|
132
|
+
def publish(
|
|
133
|
+
self,
|
|
134
|
+
tool_def: ToolDefinition,
|
|
135
|
+
force: bool = False,
|
|
136
|
+
dry_run: bool = False,
|
|
137
|
+
) -> str:
|
|
138
|
+
if self._token is None:
|
|
139
|
+
raise FuncHubError(
|
|
140
|
+
"请先运行 funchub login 配置 GitHub Token"
|
|
141
|
+
)
|
|
142
|
+
client = GitHubRegistryClient(self._token)
|
|
143
|
+
return client.publish_tool(tool_def, force=force, dry_run=dry_run)
|
|
144
|
+
|
|
145
|
+
def list_installed(self) -> List[Dict[str, str]]:
|
|
146
|
+
if not self._cache_base.exists():
|
|
147
|
+
return []
|
|
148
|
+
result = []
|
|
149
|
+
for child in self._cache_base.iterdir():
|
|
150
|
+
if child.is_dir():
|
|
151
|
+
version_file = child / ".version"
|
|
152
|
+
source_file = child / ".source_repo"
|
|
153
|
+
version = ""
|
|
154
|
+
source = ""
|
|
155
|
+
if version_file.exists():
|
|
156
|
+
version = version_file.read_text(encoding="utf-8").strip()
|
|
157
|
+
if source_file.exists():
|
|
158
|
+
source = source_file.read_text(encoding="utf-8").strip()
|
|
159
|
+
result.append({
|
|
160
|
+
"name": child.name,
|
|
161
|
+
"version": version,
|
|
162
|
+
"source_repo": source,
|
|
163
|
+
})
|
|
164
|
+
return result
|
|
165
|
+
|
|
166
|
+
def update(
|
|
167
|
+
self,
|
|
168
|
+
tool_name: str,
|
|
169
|
+
include_prerelease: bool = False,
|
|
170
|
+
yes: bool = False,
|
|
171
|
+
) -> Optional[str]:
|
|
172
|
+
registry = self._fetch_registry()
|
|
173
|
+
tool_def = registry.get(tool_name)
|
|
174
|
+
if tool_def is None:
|
|
175
|
+
raise ToolNotFoundError(tool_name)
|
|
176
|
+
|
|
177
|
+
available = [v.version for v in tool_def.versions]
|
|
178
|
+
latest = resolve_version(
|
|
179
|
+
available, "latest", include_prerelease=include_prerelease
|
|
180
|
+
)
|
|
181
|
+
if latest is None:
|
|
182
|
+
raise VersionNotFoundError("latest")
|
|
183
|
+
|
|
184
|
+
cache_dir = self._cache_base / tool_name
|
|
185
|
+
version_file = cache_dir / ".version"
|
|
186
|
+
|
|
187
|
+
if version_file.exists():
|
|
188
|
+
current = version_file.read_text(encoding="utf-8").strip()
|
|
189
|
+
if current == latest:
|
|
190
|
+
return current
|
|
191
|
+
|
|
192
|
+
self.install(tool_name, constraint=latest, include_prerelease=include_prerelease, yes=yes)
|
|
193
|
+
return latest
|
|
194
|
+
|
|
195
|
+
def update_all(
|
|
196
|
+
self,
|
|
197
|
+
include_prerelease: bool = False,
|
|
198
|
+
yes: bool = False,
|
|
199
|
+
) -> List[str]:
|
|
200
|
+
updated = []
|
|
201
|
+
installed = self.list_installed()
|
|
202
|
+
for item in installed:
|
|
203
|
+
try:
|
|
204
|
+
new_ver = self.update(
|
|
205
|
+
item["name"],
|
|
206
|
+
include_prerelease=include_prerelease,
|
|
207
|
+
yes=yes,
|
|
208
|
+
)
|
|
209
|
+
if new_ver and new_ver != item["version"]:
|
|
210
|
+
updated.append(f"{item['name']}: {item['version']} -> {new_ver}")
|
|
211
|
+
except FuncHubError:
|
|
212
|
+
pass
|
|
213
|
+
return updated
|
|
214
|
+
|
|
215
|
+
def info(self, tool_name: str) -> Optional[ToolDefinition]:
|
|
216
|
+
return self.get_tool(tool_name)
|
|
217
|
+
|
|
218
|
+
def uninstall(self, tool_name: str) -> bool:
|
|
219
|
+
cache_dir = self._cache_base / tool_name
|
|
220
|
+
if cache_dir.exists():
|
|
221
|
+
shutil.rmtree(str(cache_dir))
|
|
222
|
+
self._funcs.pop(tool_name, None)
|
|
223
|
+
return True
|
|
224
|
+
return False
|
funchub/decorators.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any, Callable, Dict, List, Optional, Type, get_type_hints
|
|
3
|
+
|
|
4
|
+
from funchub.models import ToolDefinition, ToolVersion
|
|
5
|
+
|
|
6
|
+
_TYPE_MAP: Dict[type, str] = {
|
|
7
|
+
str: "string",
|
|
8
|
+
int: "integer",
|
|
9
|
+
float: "number",
|
|
10
|
+
bool: "boolean",
|
|
11
|
+
list: "array",
|
|
12
|
+
dict: "object",
|
|
13
|
+
type(None): "null",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _py_type_to_json_type(py_type: Type) -> str:
|
|
18
|
+
return _TYPE_MAP.get(py_type, "string")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _infer_parameters(func: Callable) -> Dict[str, Any]:
|
|
22
|
+
sig = inspect.signature(func)
|
|
23
|
+
hints = get_type_hints(func)
|
|
24
|
+
properties: Dict[str, Dict[str, Any]] = {}
|
|
25
|
+
required: List[str] = []
|
|
26
|
+
|
|
27
|
+
for name, param in sig.parameters.items():
|
|
28
|
+
if name in ("self", "cls"):
|
|
29
|
+
continue
|
|
30
|
+
param_type = hints.get(name, str)
|
|
31
|
+
json_type = _py_type_to_json_type(param_type)
|
|
32
|
+
prop: Dict[str, Any] = {"type": json_type}
|
|
33
|
+
|
|
34
|
+
if param.default is not inspect.Parameter.empty:
|
|
35
|
+
prop["default"] = param.default
|
|
36
|
+
default_str = str(param.default)
|
|
37
|
+
if json_type == "string":
|
|
38
|
+
prop["description"] = f"Default: {default_str}"
|
|
39
|
+
else:
|
|
40
|
+
required.append(name)
|
|
41
|
+
|
|
42
|
+
properties[name] = prop
|
|
43
|
+
|
|
44
|
+
schema: Dict[str, Any] = {
|
|
45
|
+
"type": "object",
|
|
46
|
+
"properties": properties,
|
|
47
|
+
}
|
|
48
|
+
if required:
|
|
49
|
+
schema["required"] = required
|
|
50
|
+
return schema
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def tool(
|
|
54
|
+
name: Optional[str] = None,
|
|
55
|
+
description: Optional[str] = None,
|
|
56
|
+
author: str = "anonymous",
|
|
57
|
+
version: str = "1.0.0",
|
|
58
|
+
source_repo: str = "",
|
|
59
|
+
source_ref: str = "",
|
|
60
|
+
dependencies: Optional[List[str]] = None,
|
|
61
|
+
):
|
|
62
|
+
def decorator(func: Callable) -> Callable:
|
|
63
|
+
tool_name = name or func.__name__
|
|
64
|
+
tool_desc = description or (func.__doc__ or "").strip() or tool_name
|
|
65
|
+
parameters = _infer_parameters(func)
|
|
66
|
+
tool_version = ToolVersion(
|
|
67
|
+
version=version,
|
|
68
|
+
source_repo=source_repo,
|
|
69
|
+
source_ref=source_ref,
|
|
70
|
+
dependencies=dependencies or [],
|
|
71
|
+
is_prerelease="alpha" in version or "beta" in version or "rc" in version,
|
|
72
|
+
)
|
|
73
|
+
tool_def = ToolDefinition(
|
|
74
|
+
name=tool_name,
|
|
75
|
+
description=tool_desc,
|
|
76
|
+
parameters=parameters,
|
|
77
|
+
author=author,
|
|
78
|
+
entry_point=f"{func.__module__}:{func.__name__}",
|
|
79
|
+
versions=[tool_version],
|
|
80
|
+
)
|
|
81
|
+
func.__funchub_tool__ = tool_def
|
|
82
|
+
return func
|
|
83
|
+
|
|
84
|
+
return decorator
|
funchub/exceptions.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class FuncHubError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ToolNotFoundError(FuncHubError):
|
|
6
|
+
def __init__(self, tool_name: str) -> None:
|
|
7
|
+
super().__init__(f"工具 '{tool_name}' 未在中央索引中找到")
|
|
8
|
+
self.tool_name = tool_name
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VersionNotFoundError(FuncHubError):
|
|
12
|
+
def __init__(self, constraint: str) -> None:
|
|
13
|
+
super().__init__(f"未找到满足约束 '{constraint}' 的版本")
|
|
14
|
+
self.constraint = constraint
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConflictError(FuncHubError):
|
|
18
|
+
def __init__(self, tool_name: str, author: str) -> None:
|
|
19
|
+
super().__init__(f"工具 {tool_name} 已被 {author} 占用,使用 --force 覆盖")
|
|
20
|
+
self.tool_name = tool_name
|
|
21
|
+
self.author = author
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RegistryError(FuncHubError):
|
|
25
|
+
def __init__(self, message: str) -> None:
|
|
26
|
+
super().__init__(f"注册表错误: {message}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NetworkError(FuncHubError):
|
|
30
|
+
def __init__(self, message: str, attempts: int = 3) -> None:
|
|
31
|
+
super().__init__(f"网络请求失败 (已重试 {attempts} 次): {message}")
|
|
32
|
+
self.attempts = attempts
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfigError(FuncHubError):
|
|
36
|
+
def __init__(self, message: str) -> None:
|
|
37
|
+
super().__init__(f"配置错误: {message}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LoadError(FuncHubError):
|
|
41
|
+
def __init__(self, message: str) -> None:
|
|
42
|
+
super().__init__(f"加载失败: {message}")
|
funchub/github_client.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from funchub.exceptions import ConfigError, ConflictError, FuncHubError, NetworkError
|
|
13
|
+
from funchub.models import ToolDefinition
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config_dir() -> Path:
|
|
17
|
+
return Path.home() / ".funchub"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_config_path() -> Path:
|
|
21
|
+
return get_config_dir() / "config.yaml"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_config() -> Dict[str, Any]:
|
|
25
|
+
config_path = get_config_path()
|
|
26
|
+
if config_path.exists():
|
|
27
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
28
|
+
return yaml.safe_load(raw) or {}
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_config(cfg: Dict[str, Any]) -> None:
|
|
33
|
+
config_dir = get_config_dir()
|
|
34
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
config_path = get_config_path()
|
|
36
|
+
config_path.write_text(yaml.dump(cfg), encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_registry(cli_registry: Optional[str] = None) -> str:
|
|
40
|
+
if cli_registry:
|
|
41
|
+
return cli_registry
|
|
42
|
+
env_reg = os.environ.get("FUNCHUB_REGISTRY")
|
|
43
|
+
if env_reg:
|
|
44
|
+
return env_reg
|
|
45
|
+
cfg = load_config()
|
|
46
|
+
file_reg = cfg.get("registry")
|
|
47
|
+
if file_reg:
|
|
48
|
+
return file_reg
|
|
49
|
+
return "https://raw.githubusercontent.com/funchub-registry/registry/main"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_token(cli_token: Optional[str] = None) -> Optional[str]:
|
|
53
|
+
if cli_token:
|
|
54
|
+
return cli_token
|
|
55
|
+
env_token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
56
|
+
if env_token:
|
|
57
|
+
return env_token
|
|
58
|
+
cfg = load_config()
|
|
59
|
+
return cfg.get("github_token")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def retry_request(
|
|
63
|
+
method: str,
|
|
64
|
+
url: str,
|
|
65
|
+
*,
|
|
66
|
+
headers: Optional[Dict[str, str]] = None,
|
|
67
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
68
|
+
max_retries: int = 3,
|
|
69
|
+
) -> requests.Response:
|
|
70
|
+
last_exc: Optional[Exception] = None
|
|
71
|
+
for attempt in range(max_retries):
|
|
72
|
+
try:
|
|
73
|
+
resp = requests.request(
|
|
74
|
+
method, url, headers=headers, json=json_data, timeout=30
|
|
75
|
+
)
|
|
76
|
+
if resp.status_code in (502, 503, 504):
|
|
77
|
+
raise NetworkError(f"HTTP {resp.status_code}", attempts=attempt + 1)
|
|
78
|
+
return resp
|
|
79
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
80
|
+
last_exc = exc
|
|
81
|
+
if attempt < max_retries - 1:
|
|
82
|
+
time.sleep(2 ** attempt)
|
|
83
|
+
except NetworkError as exc:
|
|
84
|
+
last_exc = exc
|
|
85
|
+
if attempt < max_retries - 1:
|
|
86
|
+
time.sleep(2 ** attempt)
|
|
87
|
+
raise NetworkError(str(last_exc), attempts=max_retries) from last_exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GitHubRegistryClient:
|
|
91
|
+
API_BASE = "https://api.github.com"
|
|
92
|
+
|
|
93
|
+
def __init__(self, token: str) -> None:
|
|
94
|
+
self.token = token
|
|
95
|
+
self.headers = {
|
|
96
|
+
"Authorization": f"token {token}",
|
|
97
|
+
"Accept": "application/vnd.github.v3+json",
|
|
98
|
+
}
|
|
99
|
+
self.registry_repo = "funchub-registry/registry"
|
|
100
|
+
self.registry_branch = "main"
|
|
101
|
+
|
|
102
|
+
def _api_url(self, path: str) -> str:
|
|
103
|
+
return f"{self.API_BASE}{path}"
|
|
104
|
+
|
|
105
|
+
def _check_write_permission(self) -> bool:
|
|
106
|
+
url = self._api_url(f"/repos/{self.registry_repo}")
|
|
107
|
+
resp = retry_request("GET", url, headers=self.headers)
|
|
108
|
+
if resp.status_code == 200:
|
|
109
|
+
data = resp.json()
|
|
110
|
+
permissions = data.get("permissions", {})
|
|
111
|
+
return permissions.get("push", False)
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def _get_user_login(self) -> str:
|
|
115
|
+
url = self._api_url("/user")
|
|
116
|
+
resp = retry_request("GET", url, headers=self.headers)
|
|
117
|
+
resp.raise_for_status()
|
|
118
|
+
return resp.json()["login"]
|
|
119
|
+
|
|
120
|
+
def _fork_repository(self) -> Dict[str, Any]:
|
|
121
|
+
url = self._api_url(f"/repos/{self.registry_repo}/forks")
|
|
122
|
+
resp = retry_request("POST", url, headers=self.headers, json_data={})
|
|
123
|
+
if resp.status_code in (200, 201, 202):
|
|
124
|
+
return resp.json()
|
|
125
|
+
raise FuncHubError(f"Fork 失败: HTTP {resp.status_code} {resp.text}")
|
|
126
|
+
|
|
127
|
+
def _fetch_file(
|
|
128
|
+
self, repo: str, path: str, branch: Optional[str] = None
|
|
129
|
+
) -> Optional[Dict[str, Any]]:
|
|
130
|
+
url = self._api_url(f"/repos/{repo}/contents/{path}")
|
|
131
|
+
params: Dict[str, str] = {}
|
|
132
|
+
if branch:
|
|
133
|
+
params["ref"] = branch
|
|
134
|
+
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
|
135
|
+
full_url = f"{url}?{qs}" if qs else url
|
|
136
|
+
resp = retry_request("GET", full_url, headers=self.headers)
|
|
137
|
+
if resp.status_code == 404:
|
|
138
|
+
return None
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
data = resp.json()
|
|
141
|
+
if isinstance(data, dict) and "content" in data:
|
|
142
|
+
decoded = base64.b64decode(data["content"]).decode("utf-8")
|
|
143
|
+
result = json.loads(decoded)
|
|
144
|
+
result["_gh_sha"] = data.get("sha")
|
|
145
|
+
return result
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _get_default_branch_sha(self, repo: str) -> str:
|
|
149
|
+
url = self._api_url(f"/repos/{repo}/git/refs/heads/{self.registry_branch}")
|
|
150
|
+
resp = retry_request("GET", url, headers=self.headers)
|
|
151
|
+
resp.raise_for_status()
|
|
152
|
+
return resp.json()["object"]["sha"]
|
|
153
|
+
|
|
154
|
+
def _create_branch(self, repo: str, branch_name: str) -> None:
|
|
155
|
+
sha = self._get_default_branch_sha(repo)
|
|
156
|
+
url = self._api_url(f"/repos/{repo}/git/refs")
|
|
157
|
+
body = {"ref": f"refs/heads/{branch_name}", "sha": sha}
|
|
158
|
+
resp = retry_request("POST", url, headers=self.headers, json_data=body)
|
|
159
|
+
if resp.status_code not in (201, 200):
|
|
160
|
+
existing = retry_request(
|
|
161
|
+
"GET",
|
|
162
|
+
self._api_url(f"/repos/{repo}/git/refs/heads/{branch_name}"),
|
|
163
|
+
headers=self.headers,
|
|
164
|
+
)
|
|
165
|
+
if existing.status_code == 200:
|
|
166
|
+
return
|
|
167
|
+
raise FuncHubError(
|
|
168
|
+
f"创建分支失败: HTTP {resp.status_code} {resp.text}"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _commit_file(
|
|
172
|
+
self, repo: str, branch: str, path: str, content_b64: str
|
|
173
|
+
) -> None:
|
|
174
|
+
existing = self._fetch_file(repo, path, branch=branch)
|
|
175
|
+
url = self._api_url(f"/repos/{repo}/contents/{path}")
|
|
176
|
+
body: Dict[str, Any] = {
|
|
177
|
+
"message": f"发布工具 {path.split('/')[-1].replace('.json', '')}",
|
|
178
|
+
"content": content_b64,
|
|
179
|
+
"branch": branch,
|
|
180
|
+
}
|
|
181
|
+
if existing and "_gh_sha" in existing:
|
|
182
|
+
body["sha"] = existing["_gh_sha"]
|
|
183
|
+
resp = retry_request("PUT", url, headers=self.headers, json_data=body)
|
|
184
|
+
if resp.status_code not in (200, 201):
|
|
185
|
+
raise FuncHubError(
|
|
186
|
+
f"提交文件失败: HTTP {resp.status_code} {resp.text}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _create_pr(
|
|
190
|
+
self, repo: str, branch: str, title: str
|
|
191
|
+
) -> str:
|
|
192
|
+
url = self._api_url(f"/repos/{self.registry_repo}/pulls")
|
|
193
|
+
body = {
|
|
194
|
+
"title": title,
|
|
195
|
+
"head": branch,
|
|
196
|
+
"base": self.registry_branch,
|
|
197
|
+
}
|
|
198
|
+
resp = retry_request("POST", url, headers=self.headers, json_data=body)
|
|
199
|
+
if resp.status_code in (200, 201):
|
|
200
|
+
return resp.json()["html_url"]
|
|
201
|
+
raise FuncHubError(f"创建 PR 失败: HTTP {resp.status_code} {resp.text}")
|
|
202
|
+
|
|
203
|
+
def publish_tool(
|
|
204
|
+
self,
|
|
205
|
+
tool_def: ToolDefinition,
|
|
206
|
+
force: bool = False,
|
|
207
|
+
dry_run: bool = False,
|
|
208
|
+
) -> str:
|
|
209
|
+
if dry_run:
|
|
210
|
+
return f"[DRY RUN] 将提交到 {self.registry_repo}: tools/{tool_def.name}.json"
|
|
211
|
+
has_write = self._check_write_permission()
|
|
212
|
+
|
|
213
|
+
if not has_write:
|
|
214
|
+
fork_data = self._fork_repository()
|
|
215
|
+
fork_full_name = fork_data["full_name"]
|
|
216
|
+
target_repo = fork_full_name
|
|
217
|
+
else:
|
|
218
|
+
target_repo = self.registry_repo
|
|
219
|
+
|
|
220
|
+
current_content = self._fetch_file(
|
|
221
|
+
target_repo, f"tools/{tool_def.name}.json"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if current_content and current_content.get("author") != tool_def.author:
|
|
225
|
+
if not force:
|
|
226
|
+
raise ConflictError(tool_def.name, current_content.get("author", "unknown"))
|
|
227
|
+
|
|
228
|
+
new_content_json = tool_def.model_dump_json(indent=2)
|
|
229
|
+
encoded = base64.b64encode(new_content_json.encode()).decode()
|
|
230
|
+
|
|
231
|
+
branch = f"publish-{tool_def.name}"
|
|
232
|
+
self._create_branch(target_repo, branch)
|
|
233
|
+
self._commit_file(
|
|
234
|
+
target_repo, branch, f"tools/{tool_def.name}.json", encoded
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if not has_write:
|
|
238
|
+
pr_url = self._create_pr(
|
|
239
|
+
target_repo,
|
|
240
|
+
f"{self._get_user_login()}:{branch}",
|
|
241
|
+
f"发布工具: {tool_def.name}",
|
|
242
|
+
)
|
|
243
|
+
return pr_url
|
|
244
|
+
return f"https://github.com/{self.registry_repo}/tree/{branch}/tools/{tool_def.name}.json"
|
funchub/loader.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from funchub.exceptions import LoadError, NetworkError
|
|
10
|
+
from funchub.models import ToolDefinition, ToolVersion
|
|
11
|
+
from funchub.version_parser import resolve_version
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Loader:
|
|
15
|
+
_instances: Dict[str, Any] = {}
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def _git_retry(cmd: list, cwd: Optional[Path] = None, max_retries: int = 3) -> None:
|
|
19
|
+
last_exc: Optional[Exception] = None
|
|
20
|
+
for attempt in range(max_retries):
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
cmd,
|
|
24
|
+
cwd=str(cwd) if cwd else None,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
timeout=120,
|
|
28
|
+
)
|
|
29
|
+
if result.returncode == 0:
|
|
30
|
+
return
|
|
31
|
+
last_exc = subprocess.CalledProcessError(
|
|
32
|
+
result.returncode, cmd, result.stdout, result.stderr
|
|
33
|
+
)
|
|
34
|
+
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
|
|
35
|
+
last_exc = exc
|
|
36
|
+
if attempt < max_retries - 1:
|
|
37
|
+
time.sleep(2 ** attempt)
|
|
38
|
+
raise NetworkError(str(last_exc), attempts=max_retries) from last_exc
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def ensure_tool(
|
|
42
|
+
tool_def: ToolDefinition,
|
|
43
|
+
target_version_str: str,
|
|
44
|
+
cache_base: Path,
|
|
45
|
+
yes: bool = False,
|
|
46
|
+
) -> Path:
|
|
47
|
+
version_meta: Optional[ToolVersion] = None
|
|
48
|
+
for v in tool_def.versions:
|
|
49
|
+
if v.version == target_version_str:
|
|
50
|
+
version_meta = v
|
|
51
|
+
break
|
|
52
|
+
if version_meta is None:
|
|
53
|
+
raise LoadError(
|
|
54
|
+
f"版本 {target_version_str} 在工具 {tool_def.name} 中未找到"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
tool_cache = cache_base / tool_def.name
|
|
58
|
+
version_file = tool_cache / ".version"
|
|
59
|
+
source_repo_file = tool_cache / ".source_repo"
|
|
60
|
+
|
|
61
|
+
if version_file.exists():
|
|
62
|
+
cached_version = version_file.read_text(encoding="utf-8").strip()
|
|
63
|
+
if cached_version == target_version_str:
|
|
64
|
+
return tool_cache
|
|
65
|
+
|
|
66
|
+
import warnings
|
|
67
|
+
repo_url = version_meta.source_repo
|
|
68
|
+
warnings.warn(
|
|
69
|
+
f"安全警告: 此工具将从远程仓库 {repo_url} 下载并执行代码。\n"
|
|
70
|
+
f"请确保您信任该仓库的作者,并在隔离环境中使用。\n"
|
|
71
|
+
f"若要查看源码,请访问: {repo_url}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if not yes:
|
|
75
|
+
try:
|
|
76
|
+
answer = input("继续安装请按 Y,取消请按 N: ").strip().lower()
|
|
77
|
+
except (EOFError, KeyboardInterrupt):
|
|
78
|
+
raise LoadError("安装已取消")
|
|
79
|
+
if answer != "y":
|
|
80
|
+
raise LoadError("安装已取消")
|
|
81
|
+
|
|
82
|
+
source_ref = version_meta.source_ref
|
|
83
|
+
|
|
84
|
+
if tool_cache.exists():
|
|
85
|
+
Loader._git_retry(
|
|
86
|
+
["git", "fetch", "--tags", "--depth", "1"],
|
|
87
|
+
cwd=tool_cache,
|
|
88
|
+
)
|
|
89
|
+
Loader._git_retry(
|
|
90
|
+
["git", "checkout", source_ref],
|
|
91
|
+
cwd=tool_cache,
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
tool_cache.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
Loader._git_retry(
|
|
96
|
+
[
|
|
97
|
+
"git", "clone", "--depth", "1",
|
|
98
|
+
"--branch", source_ref,
|
|
99
|
+
version_meta.source_repo,
|
|
100
|
+
str(tool_cache),
|
|
101
|
+
]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if version_meta.dependencies:
|
|
105
|
+
deps = version_meta.dependencies
|
|
106
|
+
pip_cmd = [sys.executable, "-m", "pip", "install"] + deps
|
|
107
|
+
Loader._git_retry(pip_cmd)
|
|
108
|
+
|
|
109
|
+
version_file.write_text(target_version_str, encoding="utf-8")
|
|
110
|
+
source_repo_file.write_text(version_meta.source_repo, encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
return tool_cache
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def load_function(
|
|
116
|
+
tool_def: ToolDefinition,
|
|
117
|
+
target_version: str,
|
|
118
|
+
cache_base: Path,
|
|
119
|
+
yes: bool = False,
|
|
120
|
+
) -> Callable:
|
|
121
|
+
tool_cache = Loader.ensure_tool(
|
|
122
|
+
tool_def, target_version, cache_base, yes=yes
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
entry = tool_def.entry_point
|
|
126
|
+
if ":" not in entry:
|
|
127
|
+
raise LoadError(f"entry_point 格式必须为 'module.sub:func_name',got: {entry}")
|
|
128
|
+
|
|
129
|
+
module_path, func_name = entry.split(":", 1)
|
|
130
|
+
|
|
131
|
+
sys.path.insert(0, str(tool_cache))
|
|
132
|
+
try:
|
|
133
|
+
mod = importlib.import_module(module_path)
|
|
134
|
+
except ImportError as exc:
|
|
135
|
+
sys.path.pop(0)
|
|
136
|
+
raise LoadError(f"无法加载模块 '{module_path}': {exc}") from exc
|
|
137
|
+
|
|
138
|
+
if not hasattr(mod, func_name):
|
|
139
|
+
sys.path.pop(0)
|
|
140
|
+
raise LoadError(
|
|
141
|
+
f"模块 '{module_path}' 中没有函数 '{func_name}'"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
func = getattr(mod, func_name)
|
|
145
|
+
|
|
146
|
+
if not callable(func):
|
|
147
|
+
sys.path.pop(0)
|
|
148
|
+
raise LoadError(f"'{module_path}:{func_name}' 不是可调用对象")
|
|
149
|
+
|
|
150
|
+
sys.path.pop(0)
|
|
151
|
+
return func
|
funchub/models.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any, Dict, List, Optional
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ToolVersion(BaseModel):
|
|
7
|
+
version: str = Field(..., description="语义化版本,如 '2.1.0'")
|
|
8
|
+
source_repo: str = Field(..., description="完整 Git 仓库地址,含协议")
|
|
9
|
+
source_ref: str = Field(..., description="Tag 或分支名,如 'v2.1.0'")
|
|
10
|
+
dependencies: List[str] = Field(default_factory=list)
|
|
11
|
+
released_at: str = Field(
|
|
12
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
|
13
|
+
)
|
|
14
|
+
is_prerelease: bool = Field(
|
|
15
|
+
default=False, description="是否为 alpha/beta/rc 版本"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ToolDefinition(BaseModel):
|
|
20
|
+
name: str = Field(
|
|
21
|
+
...,
|
|
22
|
+
pattern=r"^[a-z][a-z0-9_\-]{1,50}$",
|
|
23
|
+
description="仅小写字母、数字、下划线、连字符",
|
|
24
|
+
)
|
|
25
|
+
description: str = Field(..., max_length=200)
|
|
26
|
+
parameters: Dict[str, Any] = Field(
|
|
27
|
+
..., description="标准 OpenAI 格式 JSON Schema"
|
|
28
|
+
)
|
|
29
|
+
author: str
|
|
30
|
+
entry_point: str = Field(
|
|
31
|
+
..., description="'module.sub:func_name' 格式"
|
|
32
|
+
)
|
|
33
|
+
versions: List[ToolVersion] = Field(default_factory=list)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from typing import List, Optional
|
|
2
|
+
from packaging.version import Version, parse, InvalidVersion
|
|
3
|
+
from packaging.specifiers import SpecifierSet
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def convert_caret_to_specifier(constraint: str) -> Optional[SpecifierSet]:
|
|
7
|
+
if not constraint.startswith("^"):
|
|
8
|
+
return None
|
|
9
|
+
ver_str = constraint[1:]
|
|
10
|
+
try:
|
|
11
|
+
ver = parse(ver_str)
|
|
12
|
+
except InvalidVersion:
|
|
13
|
+
return None
|
|
14
|
+
if ver.major == 0:
|
|
15
|
+
if ver.minor is not None and ver.minor > 0:
|
|
16
|
+
upper = f"0.{ver.minor + 1}.0"
|
|
17
|
+
elif ver.minor == 0 and ver.micro is not None and ver.micro > 0:
|
|
18
|
+
upper = f"0.0.{ver.micro + 1}"
|
|
19
|
+
else:
|
|
20
|
+
upper = f"{ver.major + 1}.0.0"
|
|
21
|
+
else:
|
|
22
|
+
upper = f"{ver.major + 1}.0.0"
|
|
23
|
+
return SpecifierSet(f">={ver_str},<{upper}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def convert_tilde_to_specifier(constraint: str) -> Optional[SpecifierSet]:
|
|
27
|
+
if not constraint.startswith("~"):
|
|
28
|
+
return None
|
|
29
|
+
ver_str = constraint[1:]
|
|
30
|
+
try:
|
|
31
|
+
ver = parse(ver_str)
|
|
32
|
+
except InvalidVersion:
|
|
33
|
+
return None
|
|
34
|
+
if ver.minor is not None:
|
|
35
|
+
upper = f"{ver.major}.{ver.minor + 1}.0"
|
|
36
|
+
else:
|
|
37
|
+
upper = f"{ver.major + 1}.0.0"
|
|
38
|
+
return SpecifierSet(f">={ver_str},<{upper}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_version(
|
|
42
|
+
available_versions: List[str],
|
|
43
|
+
constraint: Optional[str] = None,
|
|
44
|
+
include_prerelease: bool = False,
|
|
45
|
+
) -> Optional[str]:
|
|
46
|
+
if constraint in ("main", "master", "dev") or (
|
|
47
|
+
constraint is not None and constraint.startswith("branch:")
|
|
48
|
+
):
|
|
49
|
+
if constraint is not None and constraint.startswith("branch:"):
|
|
50
|
+
return constraint.replace("branch:", "")
|
|
51
|
+
return constraint
|
|
52
|
+
|
|
53
|
+
parsed: List[Version] = []
|
|
54
|
+
version_map: dict = {}
|
|
55
|
+
for v in available_versions:
|
|
56
|
+
try:
|
|
57
|
+
ver = parse(v)
|
|
58
|
+
except InvalidVersion:
|
|
59
|
+
continue
|
|
60
|
+
if not include_prerelease and ver.is_prerelease:
|
|
61
|
+
continue
|
|
62
|
+
parsed.append(ver)
|
|
63
|
+
version_map[ver] = v
|
|
64
|
+
|
|
65
|
+
if not parsed:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
if constraint is None or constraint == "latest":
|
|
69
|
+
best = max(parsed)
|
|
70
|
+
return version_map.get(best, str(best))
|
|
71
|
+
|
|
72
|
+
if constraint in available_versions:
|
|
73
|
+
return constraint
|
|
74
|
+
|
|
75
|
+
spec: Optional[SpecifierSet] = None
|
|
76
|
+
if constraint.startswith("^"):
|
|
77
|
+
spec = convert_caret_to_specifier(constraint)
|
|
78
|
+
elif constraint.startswith("~"):
|
|
79
|
+
spec = convert_tilde_to_specifier(constraint)
|
|
80
|
+
elif constraint.startswith(">") or constraint.startswith("<") or constraint.startswith("=") or constraint.startswith("!"):
|
|
81
|
+
try:
|
|
82
|
+
spec = SpecifierSet(constraint)
|
|
83
|
+
except Exception:
|
|
84
|
+
spec = None
|
|
85
|
+
elif constraint.endswith(".x") or constraint.endswith(".X"):
|
|
86
|
+
major_str = constraint[:-2].strip()
|
|
87
|
+
try:
|
|
88
|
+
major = int(major_str)
|
|
89
|
+
except ValueError:
|
|
90
|
+
return None
|
|
91
|
+
spec = SpecifierSet(f">={major}.0.0,<{major + 1}.0.0")
|
|
92
|
+
|
|
93
|
+
if spec is not None:
|
|
94
|
+
matched = [v for v in parsed if spec.contains(v)]
|
|
95
|
+
if matched:
|
|
96
|
+
best = max(matched)
|
|
97
|
+
return version_map.get(best, str(best))
|
|
98
|
+
|
|
99
|
+
return None
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: funchub-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FuncHub - A tool registry and dynamic loader for AI agents
|
|
5
|
+
Author: FuncHub Team
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: click>=8.0
|
|
10
|
+
Requires-Dist: requests>=2.28
|
|
11
|
+
Requires-Dist: packaging>=23.0
|
|
12
|
+
Requires-Dist: PyYAML>=6.0
|
|
13
|
+
Requires-Dist: pydantic>=2.0
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest-mock>=3.0; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
19
|
+
Requires-Dist: black>=23.0; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff>=0.1; extra == "dev"
|
|
21
|
+
Requires-Dist: responses>=0.25; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# FuncHub
|
|
24
|
+
|
|
25
|
+
> ⚠️ **Security Warning**: FuncHub dynamically loads and executes code from remote Git repositories. **Only install tools from sources you trust**, and use in isolated environments.
|
|
26
|
+
|
|
27
|
+
FuncHub is a bilingual (Python + NestJS) tool registry and dynamic loader designed for AI Agents. It allows developers to publish, discover, install, and dynamically invoke tool functions.
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **Dual SDKs**: Python SDK + NestJS SDK, sharing the same design philosophy
|
|
32
|
+
- **Semver version management**: Supports `^1.2.3`, `1.x`, `latest`, branch names, etc.
|
|
33
|
+
- **GitHub publishing integration**: Automatic Fork + PR workflow via GitHub API v3
|
|
34
|
+
- **Cache management**: `.version` file tracks installed versions; expired cache auto-refetches
|
|
35
|
+
- **Private registry support**: Supports custom indexes and mirrors via config
|
|
36
|
+
- **Safety confirmation**: Interactive confirmation on install; `--yes` to bypass
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
### Python
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install funchub-sdk
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### NestJS
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm install funchub-nestjs
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
### 1. Configure GitHub Token
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# Python
|
|
62
|
+
funchub login --token ghp_xxxxxxxxxxxx
|
|
63
|
+
|
|
64
|
+
# NestJS
|
|
65
|
+
npx funchub login --token ghp_xxxxxxxxxxxx
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. Search for Tools
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
funchub search scraper
|
|
72
|
+
funchub search web
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 3. Install a Tool
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Install latest version
|
|
79
|
+
funchub install web_scraper
|
|
80
|
+
|
|
81
|
+
# Install with version constraint
|
|
82
|
+
funchub install web_scraper@^1.0
|
|
83
|
+
|
|
84
|
+
# Install development branch
|
|
85
|
+
funchub install web_scraper@main
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 4. List Installed Tools
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
funchub list
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### 5. Use a Tool in Code
|
|
95
|
+
|
|
96
|
+
**Python:**
|
|
97
|
+
```python
|
|
98
|
+
from funchub import FuncHub
|
|
99
|
+
|
|
100
|
+
hub = FuncHub()
|
|
101
|
+
scraper = hub.load("web_scraper")
|
|
102
|
+
result = scraper(url="https://example.com")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**NestJS:**
|
|
106
|
+
```typescript
|
|
107
|
+
import { FuncHub } from '@funchub/nestjs';
|
|
108
|
+
|
|
109
|
+
const hub = new FuncHub();
|
|
110
|
+
const scraper = await hub.load('web_scraper');
|
|
111
|
+
const result = await scraper({ url: 'https://example.com' });
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Command Reference
|
|
117
|
+
|
|
118
|
+
| Command | Description |
|
|
119
|
+
|---------|-------------|
|
|
120
|
+
| `funchub login --token <PAT>` | Save GitHub Personal Access Token |
|
|
121
|
+
| `funchub config set <key> <value>` | Set configuration (e.g., custom registry) |
|
|
122
|
+
| `funchub publish --version v1.0.0` | Publish current directory as a tool |
|
|
123
|
+
| `funchub publish --version v1.0.0 --force` | Overwrite existing tool with same name |
|
|
124
|
+
| `funchub publish --version v1.0.0 --dry-run` | Preview without actual submission |
|
|
125
|
+
| `funchub search <query>` | Search for tools in the registry |
|
|
126
|
+
| `funchub install <name>@<constraint>` | Install a tool |
|
|
127
|
+
| `funchub list` | List locally installed tools |
|
|
128
|
+
| `funchub update <name>` | Update a tool to latest version |
|
|
129
|
+
| `funchub update --all` | Update all installed tools |
|
|
130
|
+
| `funchub info <name>` | Show tool details |
|
|
131
|
+
| `funchub uninstall <name>` | Remove local tool cache |
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Publishing a Tool
|
|
136
|
+
|
|
137
|
+
1. Create a `funchub.json` or `funchub.yaml` tool definition file in your project root:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"name": "my_tool",
|
|
142
|
+
"description": "My awesome tool",
|
|
143
|
+
"version": "1.0.0",
|
|
144
|
+
"entry_point": "src/index:handler",
|
|
145
|
+
"parameters": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"properties": {
|
|
148
|
+
"input": { "type": "string" }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
2. Publish:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
funchub publish --version v1.0.0
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
If you have write access to the registry repo, the tool is committed directly. Otherwise, a Fork + PR is automatically created.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Decorator Usage (Python)
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from funchub import funchub_tool
|
|
168
|
+
|
|
169
|
+
@funchub_tool(
|
|
170
|
+
name="web_scraper",
|
|
171
|
+
description="Scrape web page title",
|
|
172
|
+
version="1.0.0"
|
|
173
|
+
)
|
|
174
|
+
def scrape_url(url: str) -> str:
|
|
175
|
+
"""Scrape the title from a URL."""
|
|
176
|
+
import requests
|
|
177
|
+
from bs4 import BeautifulSoup
|
|
178
|
+
resp = requests.get(url)
|
|
179
|
+
soup = BeautifulSoup(resp.text, 'html.parser')
|
|
180
|
+
return soup.title.string if soup.title else "No title found"
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
Apache License 2.0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
funchub/__init__.py,sha256=ojWRucfsMbUrOh6yAPUXrGHmxWRyxmkkRCyi94kOlxQ,58
|
|
2
|
+
funchub/cli.py,sha256=5w3STpXTBdrJ5tjKwe0jaXeX0CT9nbIST2YDQbZWjwU,6446
|
|
3
|
+
funchub/client.py,sha256=aiZRwLN5wofkYhBRMJibpdKPYn5alYhSVRIRcvFc_zQ,7420
|
|
4
|
+
funchub/decorators.py,sha256=VNFWhTJ46Az1CcM0Pj3BkFBLZTdeTNfcI_8l45ejcMM,2461
|
|
5
|
+
funchub/exceptions.py,sha256=oda8Nb7cpmNDeQr6tHDZ3XV730GK5gRJDs9XhciVbgA,1330
|
|
6
|
+
funchub/github_client.py,sha256=hKx7B9Ayo6U_pJErvOMv28Uih1XR3dlb_m2XUjwiyW8,8593
|
|
7
|
+
funchub/loader.py,sha256=1BWB2psEiOC3-f6E2r8NfOLs_HQWOhGwImXjpt7pbwM,5056
|
|
8
|
+
funchub/models.py,sha256=6hYFFFa-ZtCAL4Ze14fv4IIa35ii4-8exdZFR6d2wCU,1188
|
|
9
|
+
funchub/version_parser.py,sha256=igPRN6EU-Dbh8jx1r_pUw3M621VqScS_1R9ChOZn8ng,3125
|
|
10
|
+
funchub_sdk-0.1.0.dist-info/METADATA,sha256=ZrVDI_2-ZaCqXVjRdzhR53cUIY_3XNthbAEnYIyxWvA,4742
|
|
11
|
+
funchub_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
funchub_sdk-0.1.0.dist-info/entry_points.txt,sha256=3UfvorTYeyl4hBpeyWaLGltZLkWvf9V1cUKI16vlQK0,45
|
|
13
|
+
funchub_sdk-0.1.0.dist-info/top_level.txt,sha256=5mYr-7A4qWwr2zn_hmP4y5nyJy8ikU0olTHRqCnfX8E,8
|
|
14
|
+
funchub_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
funchub
|