diff --git a/.gitignore b/.gitignore index 93c0186..25fd656 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ dist deploy/k8s/kubeconfig-*.yaml .pnpm-store /nodeskclaw/ +.venv/ +__pycache__/ +*.pyc +.pytest_cache +.mypy_cache +*.egg-info/ diff --git a/README.md b/README.md index 9bdaade..e495580 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ npm install -g @nodeskai/genehub |---|---| | [`@nodeskai/genehub`](https://www.npmjs.com/package/@nodeskai/genehub) | CLI 命令行工具 | | [`@nodeskai/genehub-sdk`](https://www.npmjs.com/package/@nodeskai/genehub-sdk) | TypeScript SDK(Adapters + Learning Engine) | +| `genehub-sdk` (PyPI / `packages/sdk/python`) | Python SDK(最小可用:Client + Adapter + GenericAdapter + LearningEngine) | | [`@nodeskai/genehub-types`](https://www.npmjs.com/package/@nodeskai/genehub-types) | 共享类型定义与 Zod Schemas | ## 快速开始 @@ -210,6 +211,7 @@ genehub/ │ │ ├── AGENTS.md # Curator 系统提示词 │ │ └── listener.ts # 事件监听器(LISTEN/NOTIFY) │ ├── sdk/typescript/ # @nodeskai/genehub-sdk - TypeScript SDK + Adapters +│ ├── sdk/python/ # genehub-sdk - Python SDK(Client + Adapter + GenericAdapter + LearningEngine) │ ├── cli/ # @nodeskai/genehub - 命令行工具 (Commander.js) │ └── web/ # 基因仓库 Web UI (React + Vite + Tailwind) ├── genes/ # 官方基因库 diff --git a/docs/architecture.md b/docs/architecture.md index 91cb72b..e79e170 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -514,7 +514,7 @@ GitHub OAuth API Key | 搜索引擎 | PostgreSQL ILIKE(当前)/ Meilisearch(Future) | 先简后繁 | | CLI | TypeScript (tsx) | 跨平台,npm 全局安装 | | Web 前端 | React 19 + Vite 7 + Tailwind CSS 4 + Radix UI | 基因浏览、搜索、API Key 管理(6 个页面) | -| SDK | TypeScript(已实现)+ Python(Future) | 覆盖主流 Agent 开发语言 | +| SDK | TypeScript(已实现)+ Python(已实现最小可用) | 覆盖主流 Agent 开发语言 | | 分发 | npm + pip + GitHub Releases | 兼容主流包管理器 | | Git Hooks | lefthook | pre-commit 执行 Biome lint | @@ -725,11 +725,19 @@ genehub/ │ │ └── package.json │ │ │ ├── sdk/ -│ │ └── typescript/ # TypeScript SDK(@nodeskai/genehub-sdk) -│ │ └── src/ -│ │ ├── client.ts # GeneHub API 客户端 -│ │ ├── learning/ # 标准学习协议引擎(L1/L2) -│ │ └── adapters/ # 产品适配器(openclaw / nanobot / generic) +│ │ ├── typescript/ # TypeScript SDK(@nodeskai/genehub-sdk) +│ │ │ └── src/ +│ │ │ ├── client.ts # GeneHub API 客户端 +│ │ │ ├── learning/ # 标准学习协议引擎(L1/L2) +│ │ │ └── adapters/ # 产品适配器(openclaw / nanobot / generic) +│ │ └── python/ # Python SDK(genehub-sdk,最小可用) +│ │ ├── pyproject.toml +│ │ ├── src/genehub_sdk/ +│ │ │ ├── client.py # GeneHubClient +│ │ │ ├── types.py # Gene / GeneManifest / 适配器类型 +│ │ │ ├── adapters/ # GeneAdapter 基类 + GenericAdapter +│ │ │ └── learning/ # LearningEngine +│ │ └── tests/ │ │ │ ├── cli/ # 命令行工具(@nodeskai/genehub) │ │ └── src/ @@ -774,7 +782,7 @@ genehub/ ``` > **未实现的目录**(计划中): -> - `packages/sdk/python/` — Python SDK(M3 计划) +> - ~~`packages/sdk/python/`~~ — Python SDK 已实现最小可用(Client + Adapter + GenericAdapter + LearningEngine) > - `adapters/` — 安装方式兼容层(clawhub / npm / pip,后续扩展) > - `genes/rules/`、`genes/protocols/` — 规则类、协议类基因(当前仅有 skills 分类) @@ -1359,7 +1367,7 @@ MINIMAX_API_KEY: sk-xxx - [x] Web 版本历史展开查看文件内容和安装命令 - [x] GitHub OAuth + API Key 认证 + 管理员角色 - [x] GitHub Actions CI/CD(lint + build + test + npm publish + Docker deploy + K8s rolling update) -- [ ] Python SDK +- [x] Python SDK(最小可用:GeneHubClient、GeneAdapter、GenericAdapter、LearningEngine) - [ ] npm / pip 分发支持 - [ ] 基因效能数据聚合与排行 - [ ] 全文搜索升级(Meilisearch) diff --git a/packages/sdk/python/README.md b/packages/sdk/python/README.md new file mode 100644 index 0000000..ed331b0 --- /dev/null +++ b/packages/sdk/python/README.md @@ -0,0 +1,96 @@ +# GeneHub Python SDK + +GeneHub 的 Python 客户端与产品适配层,与 TypeScript SDK 核心能力对齐,供 Python 侧 Agent 接入 GeneHub。 + +## 用途 + +- **GeneHubClient**:封装 Registry HTTP API,支持搜索基因、获取详情、获取 Manifest、发布基因。 +- **GeneAdapter**:产品适配器抽象基类,定义 `detect` / `install` / `uninstall` / `is_installed` / `list`。 +- **GenericAdapter**:基于文件系统的通用适配器,将基因写入 `.genehub/genes/`(可配置目录)。 +- **LearningEngine**:标准学习协议引擎,创建学习任务、检查结果(最小可用)。 + +## 目录结构 + +``` +packages/sdk/python/ +├── pyproject.toml +├── README.md +├── src/ +│ └── genehub_sdk/ +│ ├── __init__.py +│ ├── client.py # GeneHubClient +│ ├── types.py # Gene / GeneManifest / 适配器相关类型 +│ ├── adapters/ +│ │ ├── __init__.py +│ │ ├── base.py # GeneAdapter 抽象基类 +│ │ └── generic.py # GenericAdapter +│ └── learning/ +│ ├── __init__.py +│ └── engine.py # LearningEngine +└── tests/ + ├── test_client.py + ├── test_generic_adapter.py + └── test_learning_engine.py +``` + +## 使用方法 + +### 安装 + +在项目根目录使用 uv(推荐): + +```bash +uv add ./packages/sdk/python +# 或从 PyPI(发布后):uv add genehub-sdk +``` + +### 客户端 + +```python +from genehub_sdk import GeneHubClient + +client = GeneHubClient(base_url="https://registry.genehub.dev", token="ghb_xxx") +genes = client.search_genes(q="code") +gene = client.get_gene("clean-code") +manifest = client.get_manifest("clean-code", version="1.0.0") +published = client.publish(manifest) +``` + +### 适配器 + +```python +from genehub_sdk import GenericAdapter +from genehub_sdk.adapters import GeneAdapter + +adapter: GeneAdapter = GenericAdapter(genes_dir="/path/to/genes") +if adapter.detect(): + adapter.install(manifest, options={"force": True}) + adapter.uninstall("clean-code") + print(adapter.list()) +``` + +### 学习引擎 + +```python +from genehub_sdk import GeneHubClient +from genehub_sdk.adapters import GenericAdapter +from genehub_sdk.learning import LearningEngine + +engine = LearningEngine(workspace_dir=".", adapter=GenericAdapter(), client=GeneHubClient(...)) +task = engine.create_learning_task(manifest) +result = engine.check_result(manifest["slug"]) +``` + +## 技术栈 + +- Python 3.12+ +- httpx(HTTP 客户端) +- PyYAML(Manifest 序列化) +- 测试:pytest、pytest-httpx +- Lint:Ruff + +## 参考 + +- TypeScript SDK:`packages/sdk/typescript/src/` +- API 文档:`docs/architecture.md` 第 6 节 +- 学习协议:`docs/gene-learning-protocol.md` diff --git a/packages/sdk/python/pyproject.toml b/packages/sdk/python/pyproject.toml new file mode 100644 index 0000000..75754f7 --- /dev/null +++ b/packages/sdk/python/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "genehub-sdk" +version = "0.1.0" +description = "GeneHub Python SDK - API 客户端与产品适配器(最小可用)" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "httpx>=0.27.0", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-httpx>=0.30.0", + "ruff>=0.8.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/genehub_sdk"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "C4", "SIM"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["genehub_sdk"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/packages/sdk/python/src/genehub_sdk/__init__.py b/packages/sdk/python/src/genehub_sdk/__init__.py new file mode 100644 index 0000000..e013ec2 --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/__init__.py @@ -0,0 +1,27 @@ +"""GeneHub Python SDK:API 客户端与产品适配器。""" + +from genehub_sdk.adapters import GeneAdapter, GenericAdapter +from genehub_sdk.client import GeneHubClient, GeneHubError +from genehub_sdk.learning import LearningEngine +from genehub_sdk.types import ( + Gene, + GeneManifest, + InstalledGene, + InstallOptions, + InstallResult, + UninstallResult, +) + +__all__ = [ + "GeneHubClient", + "GeneHubError", + "Gene", + "GeneManifest", + "GeneAdapter", + "GenericAdapter", + "InstallOptions", + "InstallResult", + "InstalledGene", + "UninstallResult", + "LearningEngine", +] diff --git a/packages/sdk/python/src/genehub_sdk/adapters/__init__.py b/packages/sdk/python/src/genehub_sdk/adapters/__init__.py new file mode 100644 index 0000000..ac72db7 --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/adapters/__init__.py @@ -0,0 +1,6 @@ +"""产品适配器:基类与 Generic 文件系统适配器。""" + +from genehub_sdk.adapters.base import GeneAdapter +from genehub_sdk.adapters.generic import GenericAdapter + +__all__ = ["GeneAdapter", "GenericAdapter"] diff --git a/packages/sdk/python/src/genehub_sdk/adapters/base.py b/packages/sdk/python/src/genehub_sdk/adapters/base.py new file mode 100644 index 0000000..0515169 --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/adapters/base.py @@ -0,0 +1,56 @@ +"""产品适配器抽象基类,与 types/adapter.ts GeneAdapter 对齐。""" + +from abc import ABC, abstractmethod + +from genehub_sdk.types import ( + GeneManifest, + InstalledGene, + InstallOptions, + InstallResult, + UninstallOptions, + UninstallResult, +) + + +class GeneAdapter(ABC): + """将 Gene Manifest 注入到目标产品的适配器接口。""" + + @property + @abstractmethod + def product(self) -> str: + """产品标识,如 generic / openclaw / nanobot。""" + ... + + @abstractmethod + def detect(self) -> bool: + """检测当前环境是否支持该适配器。""" + ... + + @abstractmethod + def install( + self, manifest: GeneManifest, options: InstallOptions | None = None + ) -> InstallResult: + """安装基因。""" + ... + + @abstractmethod + def uninstall(self, slug: str, options: UninstallOptions | None = None) -> UninstallResult: + """卸载基因。""" + ... + + @abstractmethod + def is_installed(self, slug: str) -> bool: + """是否已安装该基因。""" + ... + + @abstractmethod + def list(self) -> list[InstalledGene]: + """列出已安装的基因。""" + ... + + def get_installed_version(self, slug: str) -> str | None: + """返回已安装版本号,未安装返回 None。默认通过 list 查找。""" + for g in self.list(): + if g["slug"] == slug: + return g["version"] + return None diff --git a/packages/sdk/python/src/genehub_sdk/adapters/generic.py b/packages/sdk/python/src/genehub_sdk/adapters/generic.py new file mode 100644 index 0000000..40b3138 --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/adapters/generic.py @@ -0,0 +1,131 @@ +"""基于文件系统的通用适配器,与 TypeScript generic.ts 对齐。""" + +from datetime import UTC, datetime +from pathlib import Path + +import yaml + +from genehub_sdk.adapters.base import GeneAdapter +from genehub_sdk.types import ( + GeneManifest, + InstalledGene, + InstallOptions, + InstallResult, + UninstallOptions, + UninstallResult, +) + +DEFAULT_GENES_DIR = Path.cwd() / ".genehub" / "genes" + + +class GenericAdapter(GeneAdapter): + """将基因写入本地目录的适配器,默认 `.genehub/genes//`。""" + + def __init__(self, genes_dir: str | Path | None = None) -> None: + self._genes_dir = Path(genes_dir) if genes_dir else DEFAULT_GENES_DIR + + @property + def product(self) -> str: + return "generic" + + def detect(self) -> bool: + return True + + def install( + self, + manifest: GeneManifest, + options: InstallOptions | None = None, + ) -> InstallResult: + opts = options or {} + target = opts.get("target_path") + if target: + target_dir = Path(target) / manifest["slug"] + else: + target_dir = self._genes_dir / manifest["slug"] + target_dir.mkdir(parents=True, exist_ok=True) + files: list[str] = [] + + gene_yaml = target_dir / "gene.yaml" + gene_yaml.write_text( + yaml.dump(manifest, allow_unicode=True, default_flow_style=False), encoding="utf-8" + ) + files.append(str(gene_yaml)) + + skill = manifest.get("skill") or {} + if skill.get("content"): + skill_path = target_dir / "SKILL.md" + skill_path.write_text(skill["content"], encoding="utf-8") + files.append(str(skill_path)) + + deps = [ + d["slug"] + for d in (manifest.get("dependencies") or []) + if isinstance(d.get("slug"), str) + ] + return { + "success": True, + "slug": manifest["slug"], + "version": manifest["version"], + "files": files, + "needs_restart": False, + "dependencies": deps, + } + + def uninstall(self, slug: str, options: UninstallOptions | None = None) -> UninstallResult: + target_dir = self._genes_dir / slug + if target_dir.exists(): + import shutil + + shutil.rmtree(target_dir, ignore_errors=True) + return { + "success": True, + "slug": slug, + "files": [str(target_dir)], + "needs_restart": False, + } + + def is_installed(self, slug: str) -> bool: + return (self._genes_dir / slug / "gene.yaml").is_file() + + def list(self) -> list[InstalledGene]: + result: list[InstalledGene] = [] + if not self._genes_dir.is_dir(): + return result + for path in self._genes_dir.iterdir(): + if not path.is_dir(): + continue + yaml_path = path / "gene.yaml" + if not yaml_path.is_file(): + continue + try: + raw = yaml_path.read_text(encoding="utf-8") + data = yaml.safe_load(raw) + version = ( + (data.get("version") or "unknown") if isinstance(data, dict) else "unknown" + ) + if not isinstance(version, str): + version = "unknown" + mtime = datetime.fromtimestamp(yaml_path.stat().st_mtime, tz=UTC) + result.append( + { + "slug": path.name, + "version": version, + "installed_at": mtime.isoformat(), + "files": [str(yaml_path)], + } + ) + except (OSError, yaml.YAMLError): + continue + return result + + def get_installed_version(self, slug: str) -> str | None: + yaml_path = self._genes_dir / slug / "gene.yaml" + if not yaml_path.is_file(): + return None + try: + data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) + if isinstance(data, dict) and isinstance(data.get("version"), str): + return data["version"] + except (OSError, yaml.YAMLError): + pass + return None diff --git a/packages/sdk/python/src/genehub_sdk/client.py b/packages/sdk/python/src/genehub_sdk/client.py new file mode 100644 index 0000000..f7f49d7 --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/client.py @@ -0,0 +1,99 @@ +"""GeneHub Registry HTTP 客户端,与 TypeScript SDK client 对齐。""" + +from typing import Any + +import httpx + +from genehub_sdk.types import Gene, GeneManifest + + +class GeneHubError(Exception): + """Registry API 返回错误时抛出。""" + + def __init__(self, message: str, error_code: str | None = None) -> None: + self.error_code = error_code + super().__init__(f"[GeneHub] {error_code or 'error'}: {message}") + + +class GeneHubClient: + """封装 GeneHub Registry API 的 HTTP 调用。""" + + def __init__(self, base_url: str, token: str | None = None) -> None: + self._base_url = base_url.rstrip("/") + self._token = token + + def _headers(self) -> dict[str, str]: + h: dict[str, str] = {"Content-Type": "application/json"} + if self._token: + h["Authorization"] = f"Bearer {self._token}" + return h + + def _request(self, method: str, path: str, **kwargs: Any) -> Any: + url = f"{self._base_url}{path}" + with httpx.Client(timeout=30.0) as client: + resp = client.request( + method, + url, + headers=self._headers(), + **kwargs, + ) + data = resp.json() + body = data if isinstance(data, dict) else {} + code = body.get("code", -1) + if not resp.is_success or code != 0: + msg = body.get("message") or f"HTTP {resp.status_code}" + err_code = body.get("error_code") + raise GeneHubError(msg, error_code=err_code) + return body.get("data") + + def search_genes( + self, + query: str = "", + *, + category: str | None = None, + tags: list[str] | None = None, + compatibility: str | None = None, + sort: str | None = None, + page: int | None = None, + page_size: int | None = None, + ) -> list[Gene]: + """搜索基因列表。返回当前页的 items;完整分页信息可后续扩展。""" + params: list[tuple[str, str]] = [] + if query: + params.append(("q", query)) + if category: + params.append(("category", category)) + if tags: + params.append(("tags", ",".join(tags))) + if compatibility: + params.append(("compatibility", compatibility)) + if sort: + params.append(("sort", sort)) + if page is not None: + params.append(("page", str(page))) + if page_size is not None: + params.append(("page_size", str(page_size))) + qs = "&".join(f"{k}={v}" for k, v in params) + path = f"/api/v1/genes?{qs}" if qs else "/api/v1/genes" + result = self._request("GET", path) + if isinstance(result, dict) and "items" in result: + return result["items"] + return result if isinstance(result, list) else [] + + def get_gene(self, slug: str) -> Gene: + """获取基因详情(最新版本)。""" + return self._request("GET", f"/api/v1/genes/{slug}") + + def get_manifest(self, slug: str, version: str | None = None) -> GeneManifest: + """获取基因 Manifest;可选 version 指定版本。""" + path = f"/api/v1/genes/{slug}/manifest" + if version: + path += f"?version={version}" + return self._request("GET", path) + + def publish(self, manifest: GeneManifest, files: dict[str, str] | None = None) -> Gene: + """发布新基因。body: { manifest, files? }。""" + body: dict[str, Any] = {"manifest": manifest} + if files: + body["files"] = files + return self._request("POST", "/api/v1/genes", json=body) diff --git a/packages/sdk/python/src/genehub_sdk/learning/__init__.py b/packages/sdk/python/src/genehub_sdk/learning/__init__.py new file mode 100644 index 0000000..f2ff885 --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/learning/__init__.py @@ -0,0 +1,5 @@ +"""标准学习协议引擎。""" + +from genehub_sdk.learning.engine import LearningEngine + +__all__ = ["LearningEngine"] diff --git a/packages/sdk/python/src/genehub_sdk/learning/engine.py b/packages/sdk/python/src/genehub_sdk/learning/engine.py new file mode 100644 index 0000000..55fc679 --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/learning/engine.py @@ -0,0 +1,120 @@ +"""标准学习协议引擎(最小可用):创建学习任务、检查结果。""" + +import re +from pathlib import Path +from typing import Any + +from genehub_sdk.types import GeneManifest + + +class LearningEngine: + """创建学习任务、解析结果文件;可选注入 Adapter/Client 用于 meta-gene 等(后续扩展)。""" + + def __init__( + self, + workspace_dir: str | Path, + *, + adapter: Any = None, + client: Any = None, + ) -> None: + self._workspace = Path(workspace_dir) + self._adapter = adapter + self._client = client + + @property + def _tasks_dir(self) -> Path: + return self._workspace / "learning-tasks" + + @property + def _results_dir(self) -> Path: + return self._workspace / "learning-results" + + def create_learning_task(self, manifest: GeneManifest) -> dict[str, Any]: + """创建学习任务,写入 learning-tasks/.md,返回任务信息。""" + self._tasks_dir.mkdir(parents=True, exist_ok=True) + self._results_dir.mkdir(parents=True, exist_ok=True) + + slug = manifest.get("slug") or "unknown" + task_id = f"learn-{slug}-{id(manifest)}" + skill = manifest.get("skill") or {} + learning = manifest.get("learning") + + task: dict[str, Any] = { + "mode": "learn", + "task_id": task_id, + "gene_slug": slug, + "gene_name": manifest.get("name") or slug, + "gene_version": manifest.get("version") or "0.0.0", + "gene_content": skill.get("content") or "", + "gene_meta": { + "name": manifest.get("name") or slug, + "description": manifest.get("description") or "", + "category": manifest.get("category") or "general", + "short_description": manifest.get("short_description") or "", + }, + "callback_path": str(self._results_dir / f"{slug}.md"), + "created_at": __import__("datetime") + .datetime.now(__import__("datetime").timezone.utc) + .isoformat(), + } + if learning: + task["learning"] = { + "objectives": learning.get("objectives"), + "scenarios": learning.get("scenarios"), + "force_deep_learn": learning.get("force_deep_learn"), + } + + md_lines = [ + "---", + f"task_id: {task_id}", + f"gene_slug: {slug}", + f"gene_name: {task['gene_name']}", + f"gene_version: {task['gene_version']}", + "---", + "", + task["gene_content"], + ] + task_path = self._tasks_dir / f"{slug}.md" + task_path.write_text("\n".join(md_lines), encoding="utf-8") + return task + + def check_result(self, slug: str) -> dict[str, Any] | None: + """读取 learning-results/.md,解析 frontmatter 与正文,返回结果或 None。""" + result_path = self._results_dir / f"{slug}.md" + if not result_path.is_file(): + return None + try: + content = result_path.read_text(encoding="utf-8") + except OSError: + return None + return self._parse_result(content) + + def _parse_result(self, content: str) -> dict[str, Any] | None: + m = re.match(r"^---\n([\s\S]*?)\n---", content) + if not m: + return None + fm = m.group(1) + get_: dict[str, str] = {} + for line in fm.splitlines(): + if ":" in line: + k, v = line.split(":", 1) + get_[k.strip()] = v.strip().strip("'\"") + + task_id = get_.get("task_id") + gene_slug = get_.get("gene_slug") + decision = get_.get("decision") + if not task_id or not gene_slug or not decision: + return None + body_start = content.find("---", 4) + body = content[body_start + 3 :].strip() if body_start != -1 else None + self_eval = get_.get("self_eval") + return { + "task_id": task_id, + "gene_slug": gene_slug, + "mode": get_.get("mode", "learn"), + "decision": decision, + "content": body or None, + "self_eval": float(self_eval) if self_eval else None, + "reason": get_.get("reason"), + "completed_at": get_.get("completed_at", ""), + } diff --git a/packages/sdk/python/src/genehub_sdk/types.py b/packages/sdk/python/src/genehub_sdk/types.py new file mode 100644 index 0000000..962aebe --- /dev/null +++ b/packages/sdk/python/src/genehub_sdk/types.py @@ -0,0 +1,145 @@ +"""GeneHub 数据类型,与 Registry API 及标准学习协议对齐。""" + +from typing import Any, TypedDict + + +class Author(TypedDict, total=False): + type: str + name: str + ref: str + + +class CompatibilityEntry(TypedDict, total=False): + product: str + min_version: str + + +class DependencyEntry(TypedDict, total=False): + slug: str + version: str + optional: bool + + +class Skill(TypedDict, total=False): + name: str + always: bool + content: str | None + file: str | None + + +class Rule(TypedDict, total=False): + name: str + content: str + applies_to: str | None + + +class GeneConfig(TypedDict, total=False): + common: dict[str, Any] + openclaw: dict[str, Any] + nanobot: dict[str, Any] + + +class LearningScenario(TypedDict, total=False): + title: str + context: str + expected_focus: str + + +class Learning(TypedDict, total=False): + force_deep_learn: bool + objectives: list[str] + scenarios: list[LearningScenario] + + +class GeneManifest(TypedDict, total=False): + slug: str + name: str + version: str + description: str + short_description: str + category: str + tags: list[str] + icon: str | None + author: Author | None + compatibility: list[CompatibilityEntry] + dependencies: list[DependencyEntry] + synergies: list[str] + skill: Skill + rules: list[Rule] + config: GeneConfig | None + learning: Learning | None + + +class Gene(TypedDict, total=False): + id: str + name: str + slug: str + version: str + description: str + short_description: str + category: str + tags: list[str] + icon: str | None + source: str + source_ref: str | None + manifest: GeneManifest + compatibility: list[str] + dependencies: list[dict[str, str]] + synergies: list[str] + author: Author + install_count: int + avg_rating: float + is_published: bool + created_at: str + updated_at: str + + +class PaginatedData(TypedDict, total=False): + items: list[Gene] + total: int + page: int + page_size: int + total_pages: int + + +class ApiResponse(TypedDict, total=False): + code: int + message: str + data: Any + error_code: str + + +# --- Adapter 类型(与 types/adapter.ts 对齐)--- + + +class InstallOptions(TypedDict, total=False): + target_path: str | None + force: bool + skip_dependencies: bool + + +class UninstallOptions(TypedDict, total=False): + keep_config: bool + + +class InstallResult(TypedDict): + success: bool + slug: str + version: str + files: list[str] + needs_restart: bool + dependencies: list[str] + + +class UninstallResult(TypedDict): + success: bool + slug: str + files: list[str] + needs_restart: bool + + +class InstalledGene(TypedDict): + slug: str + version: str + installed_at: str + files: list[str] diff --git a/packages/sdk/python/tests/test_client.py b/packages/sdk/python/tests/test_client.py new file mode 100644 index 0000000..63f59ec --- /dev/null +++ b/packages/sdk/python/tests/test_client.py @@ -0,0 +1,113 @@ +"""GeneHubClient 单元测试(pytest-httpx 模拟 HTTP)。""" + +import pytest + +from genehub_sdk import GeneHubClient, GeneHubError +from genehub_sdk.types import GeneManifest + + +def test_search_genes_returns_items(httpx_mock: pytest.FixtureRequest) -> None: + httpx_mock.add_response( + url="https://registry.example.com/api/v1/genes?q=code", + json={ + "code": 0, + "message": "success", + "data": { + "items": [ + {"id": "1", "slug": "clean-code", "name": "Clean Code", "version": "1.0.0"}, + ], + "total": 1, + "page": 1, + "page_size": 20, + "total_pages": 1, + }, + }, + ) + client = GeneHubClient(base_url="https://registry.example.com") + items = client.search_genes("code") + assert len(items) == 1 + assert items[0]["slug"] == "clean-code" + + +def test_get_gene(httpx_mock: pytest.FixtureRequest) -> None: + httpx_mock.add_response( + url="https://registry.example.com/api/v1/genes/clean-code", + json={ + "code": 0, + "message": "success", + "data": { + "id": "1", + "slug": "clean-code", + "name": "Clean Code", + "version": "1.0.0", + }, + }, + ) + client = GeneHubClient(base_url="https://registry.example.com") + gene = client.get_gene("clean-code") + assert gene["slug"] == "clean-code" + assert gene["version"] == "1.0.0" + + +def test_get_manifest(httpx_mock: pytest.FixtureRequest) -> None: + httpx_mock.add_response( + url="https://registry.example.com/api/v1/genes/clean-code/manifest?version=1.0.0", + json={ + "code": 0, + "message": "success", + "data": { + "slug": "clean-code", + "name": "Clean Code", + "version": "1.0.0", + "skill": {"name": "clean-code", "always": False}, + "compatibility": [{"product": "openclaw"}], + }, + }, + ) + client = GeneHubClient(base_url="https://registry.example.com") + manifest = client.get_manifest("clean-code", version="1.0.0") + assert manifest["slug"] == "clean-code" + assert manifest["version"] == "1.0.0" + + +def test_publish(httpx_mock: pytest.FixtureRequest) -> None: + httpx_mock.add_response( + url="https://registry.example.com/api/v1/genes", + method="POST", + json={ + "code": 0, + "message": "success", + "data": { + "id": "1", + "slug": "my-gene", + "name": "My Gene", + "version": "1.0.0", + }, + }, + ) + client = GeneHubClient(base_url="https://registry.example.com") + manifest: GeneManifest = { + "slug": "my-gene", + "name": "My Gene", + "version": "1.0.0", + "description": "Desc", + "short_description": "Short", + "category": "development", + "tags": ["ability"], + "compatibility": [{"product": "openclaw"}], + "skill": {"name": "my-gene", "always": False}, + } + gene = client.publish(manifest) + assert gene["slug"] == "my-gene" + + +def test_api_error_raises(httpx_mock: pytest.FixtureRequest) -> None: + httpx_mock.add_response( + url="https://registry.example.com/api/v1/genes/not-found", + status_code=404, + json={"code": 20001, "error_code": "gene_not_found", "message": "基因不存在", "data": None}, + ) + client = GeneHubClient(base_url="https://registry.example.com") + with pytest.raises(GeneHubError) as exc_info: + client.get_gene("not-found") + assert "gene_not_found" in str(exc_info.value) or "基因" in str(exc_info.value) diff --git a/packages/sdk/python/tests/test_generic_adapter.py b/packages/sdk/python/tests/test_generic_adapter.py new file mode 100644 index 0000000..ac80b6a --- /dev/null +++ b/packages/sdk/python/tests/test_generic_adapter.py @@ -0,0 +1,79 @@ +"""GenericAdapter 单元测试(临时目录)。""" + +from pathlib import Path + +from genehub_sdk.adapters import GenericAdapter +from genehub_sdk.types import GeneManifest + + +def _sample_manifest() -> GeneManifest: + return { + "slug": "test-gene", + "name": "Test Gene", + "version": "1.0.0", + "description": "Description", + "short_description": "Short", + "category": "development", + "tags": ["ability"], + "compatibility": [{"product": "openclaw"}], + "skill": {"name": "test-gene", "always": False, "content": "# Skill\n\nContent here."}, + "dependencies": [{"slug": "other-gene", "version": ">=1.0", "optional": False}], + } + + +def test_detect_returns_true() -> None: + adapter = GenericAdapter() + assert adapter.detect() is True + + +def test_install_creates_dir_and_files(tmp_path: Path) -> None: + adapter = GenericAdapter(genes_dir=str(tmp_path)) + manifest = _sample_manifest() + result = adapter.install(manifest) + assert result["success"] is True + assert result["slug"] == "test-gene" + assert result["version"] == "1.0.0" + assert "dependencies" in result + assert "other-gene" in result["dependencies"] + + gene_dir = tmp_path / "test-gene" + assert gene_dir.is_dir() + assert (gene_dir / "gene.yaml").is_file() + assert (gene_dir / "SKILL.md").is_file() + assert (gene_dir / "SKILL.md").read_text(encoding="utf-8") == "# Skill\n\nContent here." + + +def test_install_with_target_path(tmp_path: Path) -> None: + adapter = GenericAdapter(genes_dir=str(tmp_path)) + target = tmp_path / "custom" + target.mkdir() + result = adapter.install(_sample_manifest(), options={"target_path": str(target)}) + assert result["success"] is True + assert (target / "test-gene" / "gene.yaml").is_file() + + +def test_uninstall_removes_dir(tmp_path: Path) -> None: + adapter = GenericAdapter(genes_dir=str(tmp_path)) + adapter.install(_sample_manifest()) + assert adapter.is_installed("test-gene") is True + result = adapter.uninstall("test-gene") + assert result["success"] is True + assert result["slug"] == "test-gene" + assert adapter.is_installed("test-gene") is False + + +def test_list_returns_installed(tmp_path: Path) -> None: + adapter = GenericAdapter(genes_dir=str(tmp_path)) + assert adapter.list() == [] + adapter.install(_sample_manifest()) + listed = adapter.list() + assert len(listed) == 1 + assert listed[0]["slug"] == "test-gene" + assert listed[0]["version"] == "1.0.0" + + +def test_get_installed_version(tmp_path: Path) -> None: + adapter = GenericAdapter(genes_dir=str(tmp_path)) + assert adapter.get_installed_version("test-gene") is None + adapter.install(_sample_manifest()) + assert adapter.get_installed_version("test-gene") == "1.0.0" diff --git a/packages/sdk/python/tests/test_learning_engine.py b/packages/sdk/python/tests/test_learning_engine.py new file mode 100644 index 0000000..86e6da1 --- /dev/null +++ b/packages/sdk/python/tests/test_learning_engine.py @@ -0,0 +1,50 @@ +"""LearningEngine 单元测试(最小可用)。""" + +from pathlib import Path + +from genehub_sdk.learning import LearningEngine +from genehub_sdk.types import GeneManifest + + +def _sample_manifest() -> GeneManifest: + return { + "slug": "test-gene", + "name": "Test Gene", + "version": "1.0.0", + "description": "Desc", + "short_description": "Short", + "category": "development", + "tags": ["ability"], + "compatibility": [{"product": "openclaw"}], + "skill": {"name": "test-gene", "always": False, "content": "# Skill\n\nContent."}, + } + + +def test_create_learning_task_creates_file(tmp_path: Path) -> None: + engine = LearningEngine(workspace_dir=tmp_path) + manifest = _sample_manifest() + task = engine.create_learning_task(manifest) + assert task["gene_slug"] == "test-gene" + assert task["mode"] == "learn" + assert (tmp_path / "learning-tasks" / "test-gene.md").is_file() + assert (tmp_path / "learning-results").is_dir() + + +def test_check_result_returns_none_when_no_file(tmp_path: Path) -> None: + engine = LearningEngine(workspace_dir=tmp_path) + assert engine.check_result("test-gene") is None + + +def test_check_result_parses_result_file(tmp_path: Path) -> None: + engine = LearningEngine(workspace_dir=tmp_path) + (tmp_path / "learning-results").mkdir(parents=True, exist_ok=True) + result_path = tmp_path / "learning-results" / "test-gene.md" + result_path.write_text( + "---\ntask_id: t1\ngene_slug: test-gene\ndecision: learned\n---\n\nLearned content.", + encoding="utf-8", + ) + result = engine.check_result("test-gene") + assert result is not None + assert result["gene_slug"] == "test-gene" + assert result["decision"] == "learned" + assert "Learned content" in (result.get("content") or "") diff --git a/packages/sdk/python/uv.lock b/packages/sdk/python/uv.lock new file mode 100644 index 0000000..553313d --- /dev/null +++ b/packages/sdk/python/uv.lock @@ -0,0 +1,251 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "genehub-sdk" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-httpx" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-httpx", marker = "extra == 'dev'", specifier = ">=0.30.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-httpx" +version = "0.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/5574834da9499066fa1a5ea9c336f94dba2eae02298d36dab192fcf95c86/pytest_httpx-0.36.0.tar.gz", hash = "sha256:9edb66a5fd4388ce3c343189bc67e7e1cb50b07c2e3fc83b97d511975e8a831b", size = 56793, upload-time = "2025-12-02T16:34:57.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/d2/1eb1ea9c84f0d2033eb0b49675afdc71aa4ea801b74615f00f3c33b725e3/pytest_httpx-0.36.0-py3-none-any.whl", hash = "sha256:bd4c120bb80e142df856e825ec9f17981effb84d159f9fa29ed97e2357c3a9c8", size = 20229, upload-time = "2025-12-02T16:34:56.45Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]