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 ADDED
@@ -0,0 +1,3 @@
1
+ from funchub.client import FuncHub
2
+
3
+ __all__ = ["FuncHub"]
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}")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ funchub = funchub.cli:main
@@ -0,0 +1 @@
1
+ funchub