From 7ee1eaaf3ed55f3c6c7643ba95ca6bbe38813306 Mon Sep 17 00:00:00 2001 From: "Enrique G. Ortiz" Date: Mon, 9 Feb 2026 20:44:35 -0500 Subject: [PATCH 1/4] Release v0.1.0: Complete whet CLI implementation (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Complete implementation of **whet** — a multi-platform CLI tool that installs expert skills into AI coding agents (Claude Code, Google Antigravity, Cursor, GitHub Copilot). This PR encompasses all 6 development phases, transforming the initial AI/CV Claude Skills framework into a publishable PyPI package. ## Content Inventory - **30 Skills** — CV/ML, infrastructure, experiment tracking, code quality - **6 Agents** — Expert Coder, ML Engineer, DevOps/Infra, Data Engineer, Code Review, Test Engineer - **6 Archetypes** — pytorch-training, cv-inference, data-pipeline, library-package, model-zoo, research-notebook - **4 Adapters** — Claude Code, Google Antigravity, Cursor, GitHub Copilot ## Development Phases 1. **Foundation** — YAML frontmatter for SKILL.md, skill.toml metadata, pixi→uv migration, dynamic test discovery 2. **CLI + Adapters** — Typer CLI (`add`/`remove`/`list`/`search`/`info`/`install`), multi-platform adapters 3. **Gap Filling Wave 1** — FastAPI, AWS SageMaker, Hugging Face skills + DevOps/Infra and Data Engineer agents 4. **Settings Engine + Doctor** — `settings generate/apply/diff` with merge support, 10 health checks 5. **Scaffolding** — `whet init` with template rendering, 2 archetype templates populated 6. **Distribution + Polish** — `whet update`, Gradio/Kubernetes skills, release workflow, repo rename ## Infrastructure Changes - Renamed repo from `ai-cv-claude-skills` → `whet` (46 references updated across 39 files) - CI workflows updated for `main`/`develop` branching model - Tag-triggered release workflow: PyPI trusted publishing (OIDC) + GitHub Releases - `justfile` task runner replacing pixi tasks - `uv` for dependency/environment management ## Test Results - **203 tests passing** — skills, agents, archetypes, workflows, integration - Ruff clean (lint + format) - MyPy strict clean - Package builds: `whet-0.1.0-py3-none-any.whl` ## Post-Merge Steps 1. Set `develop` as default branch 2. Create GitHub environment `pypi` in repo Settings 3. Add trusted publisher on PyPI (owner: `ortizeg`, repo: `whet`, workflow: `release.yml`, env: `pypi`) 4. Tag and release: `git tag v0.1.0 && git push --tags` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 --- .github/PULL_REQUEST_TEMPLATE.md | 10 +- .github/workflows/docs.yml | 14 +- .github/workflows/lint.yml | 26 +- .github/workflows/release.yml | 86 + .github/workflows/test.yml | 28 +- CLAUDE.md | 139 +- README.md | 163 +- agents/code-review/agent.toml | 8 + agents/data-engineer/README.md | 46 + agents/data-engineer/SKILL.md | 940 +++++++++ agents/data-engineer/agent.toml | 8 + agents/devops-infra/README.md | 41 + agents/devops-infra/SKILL.md | 499 +++++ agents/devops-infra/agent.toml | 8 + agents/expert-coder/agent.toml | 8 + agents/ml-engineer/agent.toml | 8 + agents/test-engineer/agent.toml | 8 + .../cv-inference-service/archetype.toml | 10 + .../cv-inference-service/template/Dockerfile | 21 + .../cv-inference-service/template/README.md | 32 + .../template/pyproject.toml | 44 + .../template/src/${package_name}/__init__.py | 3 + .../template/src/${package_name}/app.py | 41 + .../template/src/${package_name}/schemas.py | 34 + .../template/tests/__init__.py | 0 .../template/tests/test_app.py | 22 + .../data-processing-pipeline/archetype.toml | 10 + archetypes/library-package/archetype.toml | 10 + archetypes/model-zoo/archetype.toml | 10 + .../pytorch-training-project/archetype.toml | 10 + .../template/README.md | 51 + .../template/configs/config.yaml | 6 + .../template/configs/model/default.yaml | 4 + .../template/configs/trainer/default.yaml | 4 + .../template/pyproject.toml | 40 + .../template/src/${package_name}/__init__.py | 3 + .../template/src/${package_name}/data.py | 74 + .../template/src/${package_name}/model.py | 82 + .../template/src/${package_name}/train.py | 34 + .../template/tests/__init__.py | 0 .../template/tests/test_model.py | 18 + archetypes/research-notebook/archetype.toml | 10 + docs/agents/code-review.md | 14 +- docs/agents/data-engineer.md | 45 + docs/agents/devops-infra.md | 95 + docs/agents/index.md | 12 +- docs/agents/test-engineer.md | 10 +- docs/archetypes/cv-inference-service.md | 2 +- docs/archetypes/data-processing-pipeline.md | 4 +- docs/archetypes/library-package.md | 6 +- docs/archetypes/model-zoo.md | 6 +- docs/archetypes/pytorch-training-project.md | 6 +- docs/archetypes/research-notebook.md | 6 +- docs/examples/full-workflow.md | 6 +- docs/getting-started/first-project.md | 4 +- docs/getting-started/installation.md | 161 +- docs/getting-started/quick-start.md | 36 +- docs/guides/adding-skills.md | 10 +- docs/guides/best-practices.md | 2 +- docs/guides/creating-projects.md | 10 +- docs/guides/customization.md | 2 +- docs/index.md | 14 +- docs/skills/abstraction-patterns.md | 2 +- docs/skills/aws-sagemaker.md | 98 + docs/skills/code-quality.md | 8 +- docs/skills/docker-cv.md | 2 +- docs/skills/dvc.md | 2 +- docs/skills/fastapi.md | 108 + docs/skills/gcp.md | 2 +- docs/skills/github-actions.md | 10 +- docs/skills/github-repo-setup.md | 2 +- docs/skills/gradio.md | 67 + docs/skills/huggingface.md | 101 + docs/skills/hydra-config.md | 2 +- docs/skills/index.md | 12 +- docs/skills/kubernetes.md | 76 + docs/skills/library-review.md | 2 +- docs/skills/loguru.md | 2 +- docs/skills/master-skill.md | 2 +- docs/skills/matplotlib.md | 2 +- docs/skills/mlflow.md | 2 +- docs/skills/onnx.md | 2 +- docs/skills/opencv.md | 2 +- docs/skills/pixi.md | 2 +- docs/skills/pre-commit.md | 6 +- docs/skills/pydantic-strict.md | 2 +- docs/skills/pypi.md | 8 +- docs/skills/pytorch-lightning.md | 2 +- docs/skills/tensorboard.md | 2 +- docs/skills/tensorrt.md | 2 +- docs/skills/testing.md | 2 +- docs/skills/vscode.md | 2 +- docs/skills/wandb.md | 2 +- justfile | 32 + mkdocs.yml | 13 +- pixi.lock | 1869 ----------------- pixi.toml | 29 - pyproject.toml | 85 +- settings.local.json | 76 +- settings/antigravity.json | 39 + settings/base.json | 30 + settings/claude.json | 48 + skills/abstraction-patterns/SKILL.md | 8 + skills/abstraction-patterns/skill.toml | 13 + skills/aws-sagemaker/README.md | 32 + skills/aws-sagemaker/SKILL.md | 624 ++++++ skills/aws-sagemaker/skill.toml | 16 + skills/code-quality/SKILL.md | 8 + skills/code-quality/skill.toml | 16 + skills/docker-cv/SKILL.md | 8 + skills/docker-cv/skill.toml | 13 + skills/dvc/SKILL.md | 8 + skills/dvc/skill.toml | 15 + skills/fastapi/README.md | 32 + skills/fastapi/SKILL.md | 609 ++++++ skills/fastapi/skill.toml | 17 + skills/gcp/SKILL.md | 8 + skills/gcp/skill.toml | 16 + skills/github-actions/SKILL.md | 8 + skills/github-actions/skill.toml | 13 + skills/github-repo-setup/SKILL.md | 8 + skills/github-repo-setup/skill.toml | 13 + skills/gradio/README.md | 38 + skills/gradio/SKILL.md | 662 ++++++ skills/gradio/skill.toml | 15 + skills/huggingface/README.md | 34 + skills/huggingface/SKILL.md | 530 +++++ skills/huggingface/skill.toml | 17 + skills/hydra-config/SKILL.md | 8 + skills/hydra-config/skill.toml | 15 + skills/kubernetes/README.md | 35 + skills/kubernetes/SKILL.md | 776 +++++++ skills/kubernetes/skill.toml | 13 + skills/library-review/SKILL.md | 8 + skills/library-review/skill.toml | 13 + skills/loguru/SKILL.md | 8 + skills/loguru/skill.toml | 15 + skills/master-skill/SKILL.md | 10 +- skills/master-skill/skill.toml | 13 + skills/matplotlib/SKILL.md | 8 + skills/matplotlib/skill.toml | 15 + skills/mlflow/SKILL.md | 8 + skills/mlflow/skill.toml | 15 + skills/onnx/SKILL.md | 8 + skills/onnx/skill.toml | 16 + skills/opencv/SKILL.md | 8 + skills/opencv/skill.toml | 15 + skills/pixi/SKILL.md | 8 + skills/pixi/skill.toml | 13 + skills/pre-commit/SKILL.md | 8 + skills/pre-commit/skill.toml | 13 + skills/pydantic-strict/SKILL.md | 8 + skills/pydantic-strict/skill.toml | 15 + skills/pypi/SKILL.md | 8 + skills/pypi/skill.toml | 13 + skills/pytorch-lightning/SKILL.md | 8 + skills/pytorch-lightning/skill.toml | 15 + skills/tensorboard/SKILL.md | 8 + skills/tensorboard/skill.toml | 15 + skills/tensorrt/SKILL.md | 8 + skills/tensorrt/skill.toml | 15 + skills/testing/SKILL.md | 8 + skills/testing/skill.toml | 16 + skills/vscode/SKILL.md | 8 + skills/vscode/skill.toml | 13 + skills/wandb/SKILL.md | 8 + skills/wandb/skill.toml | 15 + src/whet/__init__.py | 5 + src/whet/__main__.py | 7 + src/whet/adapters/__init__.py | 3 + src/whet/adapters/antigravity.py | 51 + src/whet/adapters/base.py | 35 + src/whet/adapters/claude.py | 53 + src/whet/adapters/copilot.py | 98 + src/whet/adapters/cursor.py | 58 + src/whet/adapters/detect.py | 30 + src/whet/cli/__init__.py | 64 + src/whet/cli/config.py | 49 + src/whet/cli/doctor.py | 181 ++ src/whet/cli/init.py | 116 + src/whet/cli/install.py | 133 ++ src/whet/cli/settings.py | 172 ++ src/whet/cli/skills.py | 200 ++ src/whet/cli/target.py | 44 + src/whet/cli/update.py | 43 + src/whet/core/__init__.py | 3 + src/whet/core/config.py | 59 + src/whet/core/skill.py | 175 ++ src/whet/py.typed | 0 src/whet/registry/__init__.py | 3 + src/whet/registry/loader.py | 46 + src/whet/registry/resolver.py | 36 + src/whet/registry/search.py | 36 + src/whet/scaffold/__init__.py | 1 + src/whet/scaffold/engine.py | 245 +++ src/whet/settings/__init__.py | 1 + src/whet/settings/engine.py | 136 ++ tests/test_agents.py | 44 +- tests/test_archetypes.py | 27 +- tests/test_skills_completeness.py | 73 +- uv.lock | 1158 ++++++++++ 201 files changed, 10868 insertions(+), 2349 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 agents/code-review/agent.toml create mode 100644 agents/data-engineer/README.md create mode 100644 agents/data-engineer/SKILL.md create mode 100644 agents/data-engineer/agent.toml create mode 100644 agents/devops-infra/README.md create mode 100644 agents/devops-infra/SKILL.md create mode 100644 agents/devops-infra/agent.toml create mode 100644 agents/expert-coder/agent.toml create mode 100644 agents/ml-engineer/agent.toml create mode 100644 agents/test-engineer/agent.toml create mode 100644 archetypes/cv-inference-service/archetype.toml create mode 100644 archetypes/cv-inference-service/template/Dockerfile create mode 100644 archetypes/cv-inference-service/template/README.md create mode 100644 archetypes/cv-inference-service/template/pyproject.toml create mode 100644 archetypes/cv-inference-service/template/src/${package_name}/__init__.py create mode 100644 archetypes/cv-inference-service/template/src/${package_name}/app.py create mode 100644 archetypes/cv-inference-service/template/src/${package_name}/schemas.py create mode 100644 archetypes/cv-inference-service/template/tests/__init__.py create mode 100644 archetypes/cv-inference-service/template/tests/test_app.py create mode 100644 archetypes/data-processing-pipeline/archetype.toml create mode 100644 archetypes/library-package/archetype.toml create mode 100644 archetypes/model-zoo/archetype.toml create mode 100644 archetypes/pytorch-training-project/archetype.toml create mode 100644 archetypes/pytorch-training-project/template/README.md create mode 100644 archetypes/pytorch-training-project/template/configs/config.yaml create mode 100644 archetypes/pytorch-training-project/template/configs/model/default.yaml create mode 100644 archetypes/pytorch-training-project/template/configs/trainer/default.yaml create mode 100644 archetypes/pytorch-training-project/template/pyproject.toml create mode 100644 archetypes/pytorch-training-project/template/src/${package_name}/__init__.py create mode 100644 archetypes/pytorch-training-project/template/src/${package_name}/data.py create mode 100644 archetypes/pytorch-training-project/template/src/${package_name}/model.py create mode 100644 archetypes/pytorch-training-project/template/src/${package_name}/train.py create mode 100644 archetypes/pytorch-training-project/template/tests/__init__.py create mode 100644 archetypes/pytorch-training-project/template/tests/test_model.py create mode 100644 archetypes/research-notebook/archetype.toml create mode 100644 docs/agents/data-engineer.md create mode 100644 docs/agents/devops-infra.md create mode 100644 docs/skills/aws-sagemaker.md create mode 100644 docs/skills/fastapi.md create mode 100644 docs/skills/gradio.md create mode 100644 docs/skills/huggingface.md create mode 100644 docs/skills/kubernetes.md create mode 100644 justfile delete mode 100644 pixi.lock delete mode 100644 pixi.toml create mode 100644 settings/antigravity.json create mode 100644 settings/base.json create mode 100644 settings/claude.json create mode 100644 skills/abstraction-patterns/skill.toml create mode 100644 skills/aws-sagemaker/README.md create mode 100644 skills/aws-sagemaker/SKILL.md create mode 100644 skills/aws-sagemaker/skill.toml create mode 100644 skills/code-quality/skill.toml create mode 100644 skills/docker-cv/skill.toml create mode 100644 skills/dvc/skill.toml create mode 100644 skills/fastapi/README.md create mode 100644 skills/fastapi/SKILL.md create mode 100644 skills/fastapi/skill.toml create mode 100644 skills/gcp/skill.toml create mode 100644 skills/github-actions/skill.toml create mode 100644 skills/github-repo-setup/skill.toml create mode 100644 skills/gradio/README.md create mode 100644 skills/gradio/SKILL.md create mode 100644 skills/gradio/skill.toml create mode 100644 skills/huggingface/README.md create mode 100644 skills/huggingface/SKILL.md create mode 100644 skills/huggingface/skill.toml create mode 100644 skills/hydra-config/skill.toml create mode 100644 skills/kubernetes/README.md create mode 100644 skills/kubernetes/SKILL.md create mode 100644 skills/kubernetes/skill.toml create mode 100644 skills/library-review/skill.toml create mode 100644 skills/loguru/skill.toml create mode 100644 skills/master-skill/skill.toml create mode 100644 skills/matplotlib/skill.toml create mode 100644 skills/mlflow/skill.toml create mode 100644 skills/onnx/skill.toml create mode 100644 skills/opencv/skill.toml create mode 100644 skills/pixi/skill.toml create mode 100644 skills/pre-commit/skill.toml create mode 100644 skills/pydantic-strict/skill.toml create mode 100644 skills/pypi/skill.toml create mode 100644 skills/pytorch-lightning/skill.toml create mode 100644 skills/tensorboard/skill.toml create mode 100644 skills/tensorrt/skill.toml create mode 100644 skills/testing/skill.toml create mode 100644 skills/vscode/skill.toml create mode 100644 skills/wandb/skill.toml create mode 100644 src/whet/__init__.py create mode 100644 src/whet/__main__.py create mode 100644 src/whet/adapters/__init__.py create mode 100644 src/whet/adapters/antigravity.py create mode 100644 src/whet/adapters/base.py create mode 100644 src/whet/adapters/claude.py create mode 100644 src/whet/adapters/copilot.py create mode 100644 src/whet/adapters/cursor.py create mode 100644 src/whet/adapters/detect.py create mode 100644 src/whet/cli/__init__.py create mode 100644 src/whet/cli/config.py create mode 100644 src/whet/cli/doctor.py create mode 100644 src/whet/cli/init.py create mode 100644 src/whet/cli/install.py create mode 100644 src/whet/cli/settings.py create mode 100644 src/whet/cli/skills.py create mode 100644 src/whet/cli/target.py create mode 100644 src/whet/cli/update.py create mode 100644 src/whet/core/__init__.py create mode 100644 src/whet/core/config.py create mode 100644 src/whet/core/skill.py create mode 100644 src/whet/py.typed create mode 100644 src/whet/registry/__init__.py create mode 100644 src/whet/registry/loader.py create mode 100644 src/whet/registry/resolver.py create mode 100644 src/whet/registry/search.py create mode 100644 src/whet/scaffold/__init__.py create mode 100644 src/whet/scaffold/engine.py create mode 100644 src/whet/settings/__init__.py create mode 100644 src/whet/settings/engine.py create mode 100644 uv.lock diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9dcef69..1020828 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,11 +5,11 @@ - ## Test Plan -- [ ] `pixi run test` passes -- [ ] `pixi run lint` passes -- [ ] `pixi run typecheck` passes -- [ ] `pixi run docs-build` succeeds +- [ ] `just test` passes +- [ ] `just lint` passes +- [ ] `just typecheck` passes +- [ ] `just docs-build` succeeds ## Checklist -- [ ] Skill counts updated in README.md and docs/index.md (if adding/removing skills) - [ ] CLAUDE.md reviewed (if changing repo structure) +- [ ] New skills have YAML frontmatter and skill.toml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8246e2a..384440b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,13 +19,19 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install pixi - uses: prefix-dev/setup-pixi@v0.8.1 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - pixi-version: latest + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --extra docs - name: Build docs - run: pixi run docs-build + run: uv run mkdocs build - name: Upload artifact uses: actions/upload-pages-artifact@v3 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 694d1b0..0761037 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,10 @@ name: Lint and Validate -on: [push, pull_request] +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] jobs: lint: @@ -8,23 +12,29 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install pixi - uses: prefix-dev/setup-pixi@v0.8.1 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - pixi-version: latest + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras - name: Check formatting - run: pixi run ruff format --check . + run: uv run ruff format --check . - name: Lint - run: pixi run lint + run: uv run ruff check . - name: Type check - run: pixi run typecheck + run: uv run mypy src/whet/ tests/ --strict - name: Validate YAML workflows run: | - python -c " + uv run python -c " import yaml from pathlib import Path for f in Path('.github/workflows').glob('*.yml'): diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e841cc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,86 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + dry_run: + description: "Build only, do not publish" + required: false + default: "true" + type: choice + options: + - "true" + - "false" + +permissions: + contents: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest tests/ -v + + - name: Build package + run: uv build + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-pypi: + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + create-release: + needs: [build, publish-pypi] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: dist/* diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 26631b2..2415b66 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,10 @@ -name: Test Skills Repository +name: Test -on: [push, pull_request] +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] jobs: test: @@ -8,16 +12,22 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install pixi - uses: prefix-dev/setup-pixi@v0.8.1 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - pixi-version: latest + version: "latest" - - name: Run tests - run: pixi run test-cov + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests with coverage + run: uv run pytest tests/ --cov=src/whet --cov-report=term -v - name: Run linting - run: pixi run lint + run: uv run ruff check . - name: Run type checking - run: pixi run typecheck + run: uv run mypy src/whet/ tests/ --strict diff --git a/CLAUDE.md b/CLAUDE.md index 9b13e1e..363c24a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,100 +4,137 @@ This file provides context for Claude Code when working in this repository. ## Project Overview -**AI/CV Claude Skills** is a production-ready skills framework for computer vision and deep learning projects, designed for Claude Code. It provides: +**whet** — Sharpen your AI coder. A multi-platform CLI tool that installs expert +skills into AI coding agents (Claude Code, Google Antigravity, Cursor, Copilot). -- **25 Skills** — Best-practice knowledge modules for specific tools and patterns (PyTorch Lightning, Pydantic, Docker, etc.) -- **4 Agents** — Pre-configured behavioral profiles (Expert Coder, ML Engineer, Code Review, Test Engineer) -- **6 Archetypes** — Complete project templates for common CV/ML project types +Ships CV/ML as the flagship skill collection; the framework is domain-agnostic. + +Distribution: `uvx whet` (one-shot) / `uv tool install whet` (permanent) + +### What whet provides + +- **30 Skills** — Best-practice knowledge modules (PyTorch Lightning, Pydantic, Docker, FastAPI, Hugging Face, AWS SageMaker, Gradio, Kubernetes, etc.) +- **6 Agents** — Pre-configured behavioral profiles (Expert Coder, ML Engineer, DevOps/Infra, Data Engineer, Code Review, Test Engineer) +- **6+ Archetypes** — Complete project templates for common CV/ML project types +- **CLI** — `whet add`, `whet install`, `whet list`, `whet search`, `whet doctor` +- **Multi-platform** — Claude Code, Google Antigravity, Cursor, GitHub Copilot ### Repository Structure ``` -ai-cv-claude-skills/ -├── skills/ # 25 individual skill definitions (each has SKILL.md + README.md) -├── agents/ # 4 agent definitions (each has SKILL.md + README.md + action.yml) -├── archetypes/ # 6 project templates (each has README.md + template/) -├── docs/ # MkDocs Material documentation -├── tests/ # Self-tests validating completeness -├── .github/workflows/ # CI/CD (test, lint, docs) -├── pixi.toml # Development environment and tasks -├── pyproject.toml # Tool configuration (ruff, mypy, pytest) -└── mkdocs.yml # Documentation site config +whet/ +├── src/whet/ # Python package (src-layout, Typer CLI) +│ ├── cli/ # Command modules (install, skills, target, doctor, config) +│ ├── adapters/ # Platform adapters (claude, antigravity, cursor, copilot) +│ ├── core/ # Domain models (Pydantic: skill, config) +│ └── registry/ # Skill discovery, search, dependency resolution +├── skills/ # 25+ skill definitions (SKILL.md + README.md + skill.toml) +├── agents/ # 4+ agent definitions (SKILL.md + README.md + agent.toml) +├── archetypes/ # 6+ project templates (README.md + archetype.toml + template/) +├── settings/ # Pre-built settings templates (claude.json, etc.) +├── docs/ # MkDocs Material documentation +├── tests/ # Self-tests (dynamic discovery, no hardcoded counts) +├── .github/workflows/ # CI/CD (test, lint, docs) — uses uv +├── pyproject.toml # uv-managed, publishable to PyPI +├── justfile # Task runner (replaces pixi tasks) +└── mkdocs.yml # Documentation site config ``` ## Development Commands ```bash -pixi run test # Run all tests -pixi run lint # Ruff linting -pixi run typecheck # MyPy strict type checking -pixi run ruff format --check . # Check formatting -pixi run docs-build # Build documentation site -pixi run docs-serve # Serve docs locally +just test # Run all tests +just lint # Ruff linting +just typecheck # MyPy strict type checking +just format-check # Check formatting +just docs-build # Build documentation site +just docs-serve # Serve docs locally +just check # Run all checks (lint + typecheck + test) +just dev # Install in development mode +``` + +Or directly with uv: + +```bash +uv run pytest tests/ -v +uv run ruff check . +uv run mypy src/whet/ tests/ --strict ``` ## How to Add a New Skill -1. Create `skills//SKILL.md` — Must be >500 chars, include code examples (fenced blocks) and headers -2. Create `skills//README.md` — Must explain purpose (include words like "when", "use", "purpose") -3. Create `docs/skills/.md` — Documentation page following existing pattern (Purpose, When to Use, Key Patterns, Anti-Patterns, Combines Well With) -4. Add `""` to `tests/test_skills_completeness.py` expected set -5. Add nav entry to `mkdocs.yml` under Skills section (in appropriate category position) -6. Add row to `docs/skills/index.md` in the appropriate category table -7. Update skill counts — see "Files with Hardcoded Counts" below +1. Create `skills//SKILL.md` — Must start with YAML frontmatter (`name`, `description`), be >500 chars, include code examples and headers +2. Create `skills//skill.toml` — Machine-readable metadata (category, tags, deps, compatibility) +3. Create `skills//README.md` — Must explain purpose (include words like "when", "use", "purpose") +4. Create `docs/skills/.md` — Documentation page (Purpose, When to Use, Key Patterns, Anti-Patterns) +5. Add nav entry to `mkdocs.yml` under Skills section + +Tests discover skills dynamically — no hardcoded lists to update. ## How to Add a New Agent 1. Create `agents//SKILL.md` — Must be >500 chars -2. Create `agents//README.md` — Agent overview -3. If blocking agent: create `agents//action.yml` -4. Create `docs/agents/.md` — Documentation page -5. Add `""` to `tests/test_agents.py` expected set +2. Create `agents//agent.toml` — Agent metadata (type: advisory/blocking, tags) +3. Create `agents//README.md` — Agent overview +4. If blocking agent: create `agents//action.yml` +5. Create `docs/agents/.md` — Documentation page 6. Add nav entry to `mkdocs.yml` under Agents section -7. Update agent counts in README.md and docs/index.md ## How to Add a New Archetype 1. Create `archetypes//README.md` — Must be >500 chars, include code blocks -2. Create `archetypes//template/` directory with template files -3. Create `docs/archetypes/.md` — Documentation page -4. Add `""` to `tests/test_archetypes.py` expected set +2. Create `archetypes//archetype.toml` — Archetype metadata (category, skills) +3. Create `archetypes//template/` directory with template files +4. Create `docs/archetypes/.md` — Documentation page 5. Add nav entry to `mkdocs.yml` under Archetypes section -6. Update archetype counts in README.md and docs/index.md -## Files with Hardcoded Counts +## Skill File Format -These files contain hardcoded skill/agent/archetype counts that **must** be updated when adding or removing skills, agents, or archetypes: +### SKILL.md (Universal Standard — read by LLMs) -### Skill count (currently 25) +```yaml +--- +name: skill-name +description: > + Concise description for LLM discovery. +--- -- `README.md` — lines 13, 64, 71 -- `docs/index.md` — lines 9, 15, 48, 79 +# Skill Title +(skill content...) +``` -### Agent count (currently 4) +### skill.toml (Machine-readable metadata — NOT sent to LLMs) -- `README.md` — line 14 -- `docs/index.md` — line 16 +```toml +[skill] +name = "skill-name" +version = "1.0.0" +category = "cv-ml" # core | cv-ml | infra | experiment-tracking +tags = ["tag1", "tag2"] -### Archetype count (currently 6) +[dependencies] +requires = ["other-skill"] +recommends = ["another-skill"] -- `README.md` — line 15 -- `docs/index.md` — line 17 +[compatibility] +python = ">=3.11" +libraries = { some-lib = ">=1.0" } +``` ## Conventions - **Python 3.11+** — minimum version, use modern syntax (PEP 604 unions, etc.) - **Ruff** — linting and formatting (line-length 100, rules: E, F, I, N, UP, S, B, A, C4, T20, SIM) - **MyPy strict** — all code must pass `mypy --strict` -- **Pixi** — dependency and environment management (never use pip directly) +- **uv** — dependency and environment management - **Loguru** — mandatory logging convention across all skills - **Pydantic V2** — `BaseModel` for all configs and data structures -- **src-layout** — mandatory for all project archetypes +- **src-layout** — mandatory for all project archetypes and the whet package itself ## CI/CD Three GitHub Actions workflows, all must pass before merge: -1. **test.yml** — `pixi run test-cov`, `pixi run lint`, `pixi run typecheck` -2. **lint.yml** — `ruff format --check .`, linting, type checking, YAML validation +1. **test.yml** — `uv run pytest`, `uv run ruff check`, `uv run mypy --strict` +2. **lint.yml** — `ruff format --check`, linting, type checking, YAML validation 3. **docs.yml** — Builds and deploys MkDocs to GitHub Pages (main branch only) diff --git a/README.md b/README.md index 3d798ba..567ead1 100644 --- a/README.md +++ b/README.md @@ -1,111 +1,108 @@ -# AI/CV Claude Skills +# whet -[![Tests](https://github.com/ortizeg/ai-cv-claude-skills/actions/workflows/test.yml/badge.svg)](https://github.com/ortizeg/ai-cv-claude-skills/actions/workflows/test.yml) -[![Docs](https://github.com/ortizeg/ai-cv-claude-skills/actions/workflows/docs.yml/badge.svg)](https://ortizeg.github.io/ai-cv-claude-skills/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -Production-ready skills framework for computer vision and deep learning projects using Claude Code. - -## What is This? +``` + ██╗ ██╗██╗ ██╗███████╗████████╗ + ██║ ██║██║ ██║██╔════╝╚══██╔══╝ + ██║ █╗ ██║███████║█████╗ ██║ + ██║███╗██║██╔══██║██╔══╝ ██║ + ╚███╔███╔╝██║ ██║███████╗ ██║ + ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ + sharpen your AI coder +``` -A comprehensive, prescriptive framework providing: +[![Tests](https://github.com/ortizeg/whet/actions/workflows/test.yml/badge.svg)](https://github.com/ortizeg/whet/actions/workflows/test.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) -- **25 Skills**: Best practices for PyTorch, Pydantic, Docker, testing, and more -- **4 Specialized Agents**: Code review, testing, ML engineering, and expert coding -- **6 Project Archetypes**: Ready-to-use templates for common CV/ML workflows -- **Enforced Standards**: Type safety, code quality, and testing requirements +**Install expert skills into AI coding agents.** Works with Claude Code, Google Antigravity, Cursor, and GitHub Copilot. ## Quick Start ```bash -# Clone the repository -git clone https://github.com/ortizeg/ai-cv-claude-skills.git +# One-shot (no install needed) +uvx whet list + +# Or install permanently +uv tool install whet + +# Install all skills to Claude Code +whet install --global -# Create a new project with Claude Code -claude +# Add specific skills to your project +whet add pytorch-lightning wandb hydra-config -You: "Using ai-cv-claude-skills, create a new pytorch-training-project for object detection" +# Scaffold a new project +whet init pytorch-training-project ``` -## Core Principles - -1. **Abstraction First** — Wrap external libraries (VideoReader, ImageLoader, etc.) -2. **Pydantic Everywhere** — Use Pydantic V2 BaseModel for configs and data structures -3. **Type Safety** — Full type hints with mypy strict mode, no `Any` unless unavoidable -4. **Prescriptive Standards** — Enforce configurations, block violations in CI -5. **Testing Required** — 80%+ coverage, comprehensive test suites - -## Features - -### Prescriptive Standards -- Full type hints (mypy strict mode) -- Pydantic V2 for all configs and data structures -- 80%+ test coverage requirement -- Ruff formatting and linting - -### Four Agents -| Agent | Role | Strictness | -|-------|------|------------| -| **Expert Coder** | Primary development assistant | Advisory | -| **ML Engineer** | Architecture and training guidance | Advisory | -| **Code Review** | Automated quality checks | Blocking | -| **Test Engineer** | Coverage enforcement | Blocking | - -### Six Archetypes -| Archetype | Use Case | -|-----------|----------| -| PyTorch Training Project | Model training with Lightning, Hydra, experiment tracking | -| CV Inference Service | FastAPI + ONNX deployment | -| Research Notebook | Jupyter-based experimentation | -| Library Package | Reusable Python package for PyPI | -| Data Processing Pipeline | ETL workflows for datasets | -| Model Zoo | Collection of pretrained models | - -### 25 Skills -Skills cover PyTorch Lightning, Pydantic, code quality, Docker, Hydra, testing, OpenCV, visualization, packaging, CI/CD, editor config, pre-commit hooks, experiment tracking (W&B, MLflow, TensorBoard), data versioning (DVC), ONNX export, TensorRT inference, abstraction patterns, library evaluation, and GitHub repository setup. - -## Repository Structure +## What is whet? + +whet is a CLI tool that installs curated expert skill definitions into AI coding agents. Each skill is a focused knowledge module (300-600 lines of expert guidance) that teaches your AI coder how to use a specific tool or pattern correctly. + +### Flagship Collection: CV/ML + +- **30 Skills** — PyTorch Lightning, Pydantic, Docker, ONNX, TensorRT, OpenCV, FastAPI, Hugging Face, AWS SageMaker, Gradio, Kubernetes, and more +- **6 Agents** — Expert Coder, ML Engineer, DevOps/Infra, Data Engineer, Code Review, Test Engineer +- **6+ Archetypes** — Training pipelines, inference services, notebooks, packages + +### Multi-Platform + +| Platform | Status | Skill Format | +|----------|--------|-------------| +| **Claude Code** | Native | SKILL.md (direct copy) | +| **Google Antigravity** | Native | SKILL.md (direct copy) | +| **Cursor** | Adapted | .md rules (converted) | +| **GitHub Copilot** | Adapted | Single aggregated file | + +## Commands ``` -ai-cv-claude-skills/ -├── skills/ # 25 individual skill definitions -├── agents/ # 4 agent definitions -├── archetypes/ # 6 project templates -├── docs/ # MkDocs Material documentation -├── tests/ # Self-tests for the skills repo -├── pixi.toml # Development environment -├── pyproject.toml # Tool configuration -└── .github/ # CI/CD workflows +whet # Show banner + help +whet add [...] # Add skill(s) to current project +whet remove # Remove a skill +whet install [--global|--local] # Install full skill collection +whet list [--installed] [--cat] # Browse available skills +whet search # Search by name/tag/description +whet info # Show skill details + deps +whet target # Set default platform +whet doctor # Health check +whet config [key] [value] # Get/set configuration ``` -## Documentation - -Full documentation: [https://ortizeg.github.io/ai-cv-claude-skills/](https://ortizeg.github.io/ai-cv-claude-skills/) +## How It Works -- [Getting Started](https://ortizeg.github.io/ai-cv-claude-skills/getting-started/installation/) -- [Skills Reference](https://ortizeg.github.io/ai-cv-claude-skills/skills/) -- [Agents Guide](https://ortizeg.github.io/ai-cv-claude-skills/agents/) -- [Project Archetypes](https://ortizeg.github.io/ai-cv-claude-skills/archetypes/) +Skills follow the universal SKILL.md standard — YAML frontmatter for discovery, markdown content for the LLM: -## Development +```yaml +--- +name: pytorch-lightning +description: > + PyTorch Lightning training pipeline patterns. +--- -```bash -# Install dependencies -pixi install +# PyTorch Lightning Skill +(300-600 lines of expert guidance...) +``` -# Run tests -pixi run test +When you run `whet add pytorch-lightning`, whet copies the skill to your platform's skill directory (e.g., `.claude/skills/`). The AI agent automatically discovers and uses the skill based on your project context. -# Run linting -pixi run lint +## Development -# Build documentation -pixi run docs-serve +```bash +# Clone and install +git clone https://github.com/ortizeg/whet.git +cd whet +uv sync --all-extras + +# Run checks +just test +just lint +just typecheck ``` ## Contributing -Contributions welcome! See [Adding Skills Guide](docs/guides/adding-skills.md) for how to add new skills. +Contributions welcome! See [CLAUDE.md](CLAUDE.md) for how to add new skills, agents, or archetypes. ## License diff --git a/agents/code-review/agent.toml b/agents/code-review/agent.toml new file mode 100644 index 0000000..fb08876 --- /dev/null +++ b/agents/code-review/agent.toml @@ -0,0 +1,8 @@ +[agent] +name = "code-review" +version = "1.0.0" +type = "blocking" +tags = ["code-review", "quality", "ci-cd", "enforcement"] + +[ci] +has_action = true diff --git a/agents/data-engineer/README.md b/agents/data-engineer/README.md new file mode 100644 index 0000000..a642530 --- /dev/null +++ b/agents/data-engineer/README.md @@ -0,0 +1,46 @@ +# Data Engineer Agent + +Advisory agent specialized in data pipeline architecture, data quality, and data lifecycle management for ML/CV projects. + +## Purpose + +This agent guides the design and implementation of robust data pipelines for computer vision and machine learning workflows. It covers ETL/ELT patterns, data validation with Pydantic and Great Expectations, dataset versioning with DVC, splitting strategies that prevent data leakage, augmentation pipeline design, storage format selection (Parquet, Arrow, LMDB, TFRecord, WebDataset), data quality monitoring, large-scale processing, and schema evolution. + +## Strictness Level + +**ADVISORY** — This agent suggests best practices and warns about anti-patterns but does not block commits or CI pipelines. + +## When to Use + +- Building data ingestion or ETL pipelines for image datasets. +- Designing data quality checks and validation suites with Great Expectations. +- Choosing storage formats (Parquet vs. LMDB vs. WebDataset vs. TFRecord). +- Setting up dataset versioning with DVC and cloud remotes. +- Splitting datasets with stratification and group-aware leakage prevention. +- Designing data augmentation pipelines with Albumentations. +- Processing large-scale datasets that exceed memory (streaming, chunking). +- Evolving dataset schemas across project versions with migration paths. +- Reviewing data pipeline code for correctness, performance, and reproducibility. + +## Example Session + +``` +You: "I have 500K medical images from 2,000 patients. How should I organize the data pipeline?" + +Data Engineer Agent: "For medical imaging at this scale, I recommend: +1. Store images in LMDB for fast random access during training +2. Build a Parquet manifest with file hashes, dimensions, patient IDs, and labels +3. Use GROUP-AWARE splitting on patient_id — all images from one patient + must be in the same split to prevent data leakage +4. Version everything with DVC using an S3 remote +5. Add Great Expectations validation: no nulls, no duplicate hashes, + valid dimensions, balanced class distribution per split +6. Define augmentation configs with Pydantic, scaled by a strength parameter" +``` + +## Related Skills + +- `dvc` — Dataset versioning, pipeline reproducibility, and remote storage +- `pydantic-strict` — Validated configuration objects and schema definitions +- `testing` — Unit tests for transforms, integration tests for pipeline stages +- `pytorch-lightning` — DataModule integration for feeding validated data into training diff --git a/agents/data-engineer/SKILL.md b/agents/data-engineer/SKILL.md new file mode 100644 index 0000000..c6512fe --- /dev/null +++ b/agents/data-engineer/SKILL.md @@ -0,0 +1,940 @@ +--- +name: data-engineer +description: > + Data pipeline architecture and data quality advisory agent for ML/CV projects. + Guides ETL/ELT design, data validation, versioning with DVC, dataset splitting + strategies, augmentation pipeline design, storage format selection, data quality + monitoring, large-scale processing patterns, and schema evolution. +--- + +# Data Engineer Agent + +You are a Data Engineer Agent specializing in data pipeline architecture, data quality, and data lifecycle management for computer vision and machine learning projects. You provide expert guidance on building robust, reproducible, and scalable data pipelines that feed into ML training and inference workflows. + +## Core Principles + +1. **Data Quality First:** No model can compensate for bad data. Validate early, validate often, and treat data quality as a first-class concern alongside code quality. +2. **Reproducibility:** Every dataset version, transformation, and split must be traceable. Use content-addressable storage and deterministic pipelines. +3. **Schema as Contract:** Data schemas define the contract between pipeline stages. Schema changes require explicit migrations, never silent mutations. +4. **Immutable Datasets:** Published dataset versions are never modified in place. New versions are created with clear lineage back to their source. +5. **Fail Fast, Fail Loud:** Data pipelines must raise errors immediately on quality violations rather than silently propagating corrupt data downstream. + +## Data Pipeline Architecture + +### Decision Framework + +``` +Building a data pipeline? +├── Small dataset (< 10 GB) → Pandas / Polars + DVC +├── Medium dataset (10-500 GB) → Polars / DuckDB + DVC + Parquet +├── Large dataset (500 GB - 10 TB) → Apache Arrow / Spark + cloud storage +├── Streaming data → Kafka / Flink + Delta Lake +└── Annotation pipeline → Label Studio / CVAT + validation hooks +``` + +### Pipeline Structure with Pydantic + +```python +"""Data pipeline configuration and orchestration.""" + +from __future__ import annotations + +from enum import Enum +from pathlib import Path + +from pydantic import BaseModel, Field, field_validator +from loguru import logger + + +class StorageFormat(str, Enum): + """Supported storage formats for CV datasets.""" + PARQUET = "parquet" + ARROW = "arrow" + LMDB = "lmdb" + TFRECORD = "tfrecord" + WEBDATASET = "webdataset" + + +class PipelineConfig(BaseModel): + """Top-level data pipeline configuration.""" + name: str = Field(min_length=1, description="Pipeline name") + source_dir: Path + output_dir: Path + storage_format: StorageFormat = StorageFormat.PARQUET + num_workers: int = Field(ge=1, le=64, default=8) + chunk_size: int = Field(ge=100, default=10_000) + validate_on_write: bool = True + schema_version: str = Field(pattern=r"^\d+\.\d+\.\d+$", default="1.0.0") + + @field_validator("output_dir") + @classmethod + def output_must_differ_from_source(cls, v: Path, info) -> Path: # noqa: N805 + """Ensure output is not the same as source to prevent data loss.""" + if "source_dir" in info.data and v == info.data["source_dir"]: + msg = "output_dir must differ from source_dir" + raise ValueError(msg) + return v + + +# CORRECT: Pipeline with explicit stages +class DataPipeline: + """Orchestrates data processing stages.""" + + def __init__(self, config: PipelineConfig) -> None: + self.config = config + logger.info("Initializing pipeline: {}", config.name) + + def run(self) -> None: + """Execute all pipeline stages in order.""" + logger.info("Starting pipeline run for {}", self.config.name) + self.extract() + self.validate_raw() + self.transform() + self.validate_transformed() + self.load() + logger.info("Pipeline run complete") + + def extract(self) -> None: + """Extract raw data from source.""" + logger.info("Extracting from {}", self.config.source_dir) + ... + + def validate_raw(self) -> None: + """Validate raw data before transformation.""" + ... + + def transform(self) -> None: + """Apply transformations.""" + ... + + def validate_transformed(self) -> None: + """Validate transformed data before loading.""" + ... + + def load(self) -> None: + """Write to output in the configured format.""" + logger.info( + "Loading to {} as {}", + self.config.output_dir, + self.config.storage_format.value, + ) + ... + + +# WRONG: No config, no validation, no logging +def process_data(input_path, output_path): + import glob + files = glob.glob(f"{input_path}/*.jpg") + for f in files: + img = cv2.imread(f) + cv2.imwrite(f"{output_path}/{os.path.basename(f)}", img) +``` + +## ETL/ELT Patterns + +### ETL for Image Datasets + +```python +"""ETL pipeline for image classification datasets.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import polars as pl +from PIL import Image +from pydantic import BaseModel, Field +from loguru import logger + + +class ImageRecord(BaseModel): + """Validated image metadata record.""" + file_hash: str = Field(min_length=64, max_length=64, description="SHA-256 hash") + relative_path: str + width: int = Field(ge=1) + height: int = Field(ge=1) + channels: int = Field(ge=1, le=4) + label: str + split: str = Field(pattern=r"^(train|val|test)$") + file_size_bytes: int = Field(ge=1) + + +def compute_file_hash(path: Path) -> str: + """Compute SHA-256 hash for content-addressable storage.""" + hasher = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def extract_image_metadata(image_path: Path, label: str, split: str) -> ImageRecord: + """Extract and validate metadata from a single image.""" + img = Image.open(image_path) + width, height = img.size + channels = len(img.getbands()) + + return ImageRecord( + file_hash=compute_file_hash(image_path), + relative_path=str(image_path), + width=width, + height=height, + channels=channels, + label=label, + split=split, + file_size_bytes=image_path.stat().st_size, + ) + + +def build_manifest(data_dir: Path, output_path: Path) -> None: + """Build a validated dataset manifest as Parquet.""" + records: list[dict] = [] + + for split_dir in sorted(data_dir.iterdir()): + if not split_dir.is_dir(): + continue + split_name = split_dir.name + for class_dir in sorted(split_dir.iterdir()): + if not class_dir.is_dir(): + continue + label = class_dir.name + for img_path in sorted(class_dir.glob("*.jpg")): + record = extract_image_metadata(img_path, label, split_name) + records.append(record.model_dump()) + + df = pl.DataFrame(records) + df.write_parquet(output_path) + logger.info("Manifest written: {} records to {}", len(records), output_path) + + +# WRONG: No validation, no hashing, CSV instead of Parquet +# with open("manifest.csv", "w") as f: +# for img in glob.glob("data/**/*.jpg"): +# f.write(f"{img},{os.path.getsize(img)}\n") +``` + +## Data Validation with Great Expectations + +### Expectation Suites for CV Datasets + +```python +"""Data validation using Great Expectations and Pydantic.""" + +from __future__ import annotations + +import great_expectations as gx +from loguru import logger + + +def create_image_dataset_expectations(context: gx.DataContext) -> None: + """Define expectations for an image classification dataset.""" + suite = context.add_expectation_suite("image_classification_suite") + + # Schema expectations + suite.add_expectation( + gx.expectations.ExpectTableColumnsToMatchOrderedList( + column_list=[ + "file_hash", "relative_path", "width", "height", + "channels", "label", "split", "file_size_bytes", + ] + ) + ) + + # No null values in critical columns + for col in ["file_hash", "relative_path", "label", "split"]: + suite.add_expectation( + gx.expectations.ExpectColumnValuesToNotBeNull(column=col) + ) + + # Value range checks + suite.add_expectation( + gx.expectations.ExpectColumnValuesToBeBetween( + column="width", min_value=32, max_value=8192, + ) + ) + suite.add_expectation( + gx.expectations.ExpectColumnValuesToBeBetween( + column="height", min_value=32, max_value=8192, + ) + ) + + # Split values must be valid + suite.add_expectation( + gx.expectations.ExpectColumnValuesToBeInSet( + column="split", value_set=["train", "val", "test"], + ) + ) + + # No duplicate file hashes (detect duplicate images) + suite.add_expectation( + gx.expectations.ExpectColumnValuesToBeUnique(column="file_hash") + ) + + logger.info("Created expectation suite with {} expectations", len(suite.expectations)) + + +def run_validation(context: gx.DataContext, batch: gx.dataset.Dataset) -> bool: + """Run validation and log results.""" + result = context.run_validation_operator( + "action_list_operator", + assets_to_validate=[batch], + ) + success = result["success"] + if not success: + logger.error("Data validation FAILED — check Great Expectations report") + else: + logger.info("Data validation PASSED") + return success +``` + +## Data Versioning with DVC + +### DVC Pipeline Configuration + +```yaml +# dvc.yaml — Reproducible data pipeline +stages: + download: + cmd: python -m src.data.download --config configs/data.yaml + deps: + - src/data/download.py + - configs/data.yaml + outs: + - data/raw/ + + preprocess: + cmd: python -m src.data.preprocess --config configs/data.yaml + deps: + - src/data/preprocess.py + - configs/data.yaml + - data/raw/ + params: + - preprocess.image_size + - preprocess.normalize + outs: + - data/processed/ + + split: + cmd: python -m src.data.split --config configs/data.yaml + deps: + - src/data/split.py + - data/processed/ + params: + - split.train_ratio + - split.val_ratio + - split.test_ratio + - split.seed + outs: + - data/splits/train/ + - data/splits/val/ + - data/splits/test/ + metrics: + - data/splits/statistics.json: + cache: false + + validate: + cmd: python -m src.data.validate --config configs/data.yaml + deps: + - src/data/validate.py + - data/splits/ + metrics: + - data/validation_report.json: + cache: false +``` + +### DVC Remote Storage Setup + +```bash +# CORRECT: Configure DVC with cloud remote +dvc init +dvc remote add -d myremote s3://my-bucket/dvc-store +dvc remote modify myremote region us-east-1 + +# Track large data files +dvc add data/raw/images/ +git add data/raw/images.dvc data/raw/.gitignore +git commit -m "Track raw images with DVC" +dvc push + +# WRONG: Committing large data directly to git +# git add data/raw/images/ # Never do this! +# git lfs track "*.jpg" # DVC is better for ML datasets +``` + +## Dataset Splitting Strategies + +### Stratified Splitting for CV + +```python +"""Dataset splitting with stratification and leak prevention.""" + +from __future__ import annotations + +from pathlib import Path + +import polars as pl +from pydantic import BaseModel, Field, model_validator +from loguru import logger + + +class SplitConfig(BaseModel): + """Configuration for dataset splitting.""" + train_ratio: float = Field(gt=0.0, lt=1.0, default=0.7) + val_ratio: float = Field(gt=0.0, lt=1.0, default=0.15) + test_ratio: float = Field(gt=0.0, lt=1.0, default=0.15) + seed: int = 42 + stratify_column: str = "label" + group_column: str | None = None # Prevent data leakage across groups + + @model_validator(mode="after") + def ratios_must_sum_to_one(self) -> SplitConfig: + """Ensure split ratios sum to 1.0.""" + total = self.train_ratio + self.val_ratio + self.test_ratio + if abs(total - 1.0) > 1e-6: + msg = f"Split ratios must sum to 1.0, got {total}" + raise ValueError(msg) + return self + + +def split_dataset( + manifest: pl.DataFrame, + config: SplitConfig, +) -> dict[str, pl.DataFrame]: + """Split dataset with stratification and optional grouping. + + When group_column is set, all records sharing the same group value + (e.g., patient_id, video_id, scene_id) go into the same split. + This prevents data leakage from correlated samples. + """ + if config.group_column is not None: + return _group_aware_split(manifest, config) + return _stratified_split(manifest, config) + + +def _stratified_split( + df: pl.DataFrame, + config: SplitConfig, +) -> dict[str, pl.DataFrame]: + """Per-class stratified random split.""" + train_parts, val_parts, test_parts = [], [], [] + + for label in sorted(df[config.stratify_column].unique().to_list()): + subset = df.filter(pl.col(config.stratify_column) == label).sample( + fraction=1.0, seed=config.seed, shuffle=True, + ) + n = len(subset) + n_train = int(n * config.train_ratio) + n_val = int(n * config.val_ratio) + + train_parts.append(subset[:n_train]) + val_parts.append(subset[n_train : n_train + n_val]) + test_parts.append(subset[n_train + n_val :]) + + splits = { + "train": pl.concat(train_parts), + "val": pl.concat(val_parts), + "test": pl.concat(test_parts), + } + + for name, split_df in splits.items(): + logger.info("Split '{}': {} records", name, len(split_df)) + + return splits + + +def _group_aware_split( + df: pl.DataFrame, + config: SplitConfig, +) -> dict[str, pl.DataFrame]: + """Split by group to prevent data leakage (e.g., same patient in train+test).""" + groups = df[config.group_column].unique().sample( + fraction=1.0, seed=config.seed, shuffle=True, + ) + n = len(groups) + n_train = int(n * config.train_ratio) + n_val = int(n * config.val_ratio) + + train_groups = set(groups[:n_train].to_list()) + val_groups = set(groups[n_train : n_train + n_val].to_list()) + test_groups = set(groups[n_train + n_val :].to_list()) + + splits = { + "train": df.filter(pl.col(config.group_column).is_in(train_groups)), + "val": df.filter(pl.col(config.group_column).is_in(val_groups)), + "test": df.filter(pl.col(config.group_column).is_in(test_groups)), + } + + # Verify no leakage + assert train_groups.isdisjoint(val_groups), "Train/val group overlap!" + assert train_groups.isdisjoint(test_groups), "Train/test group overlap!" + assert val_groups.isdisjoint(test_groups), "Val/test group overlap!" + + for name, split_df in splits.items(): + logger.info("Split '{}': {} records ({} groups)", name, len(split_df), + split_df[config.group_column].n_unique()) + + return splits + + +# WRONG: Random split without stratification or group awareness +# train, val, test = np.split(df.sample(frac=1), [int(.7*len(df)), int(.85*len(df))]) +``` + +## Data Augmentation Pipeline Design + +### Albumentations Pipeline with Config + +```python +"""Data augmentation pipeline with Pydantic configuration.""" + +from __future__ import annotations + +import albumentations as A +from albumentations.pytorch import ToTensorV2 +from pydantic import BaseModel, Field +from loguru import logger + + +class AugmentationConfig(BaseModel): + """Augmentation pipeline configuration.""" + image_size: int = Field(ge=32, default=640) + strength: float = Field(ge=0.0, le=1.0, default=0.5) + normalize_mean: tuple[float, float, float] = (0.485, 0.456, 0.406) + normalize_std: tuple[float, float, float] = (0.229, 0.224, 0.225) + mosaic_probability: float = Field(ge=0.0, le=1.0, default=0.5) + mixup_alpha: float = Field(ge=0.0, default=0.2) + + +def build_train_transforms(config: AugmentationConfig) -> A.Compose: + """Build training augmentation pipeline scaled by strength.""" + s = config.strength + transforms = A.Compose([ + A.RandomResizedCrop( + height=config.image_size, + width=config.image_size, + scale=(0.5 + 0.3 * (1 - s), 1.0), + ), + A.HorizontalFlip(p=0.5), + A.VerticalFlip(p=0.1 * s), + A.ColorJitter( + brightness=0.2 * s, + contrast=0.2 * s, + saturation=0.2 * s, + hue=0.05 * s, + p=0.8, + ), + A.GaussNoise(p=0.3 * s), + A.GaussianBlur(blur_limit=(3, 7), p=0.2 * s), + A.CoarseDropout( + max_holes=int(8 * s), + max_height=int(config.image_size * 0.1), + max_width=int(config.image_size * 0.1), + p=0.3 * s, + ), + A.Normalize(mean=config.normalize_mean, std=config.normalize_std), + ToTensorV2(), + ], bbox_params=A.BboxParams(format="pascal_voc", label_fields=["class_labels"])) + + logger.info("Built training transforms (strength={}): {} ops", s, len(transforms)) + return transforms + + +def build_val_transforms(config: AugmentationConfig) -> A.Compose: + """Build deterministic validation transforms (no randomness).""" + return A.Compose([ + A.Resize(height=config.image_size, width=config.image_size), + A.Normalize(mean=config.normalize_mean, std=config.normalize_std), + ToTensorV2(), + ]) + + +# WRONG: Hard-coded transforms, no config, train augmentations on val set +# transform = transforms.Compose([ +# transforms.RandomHorizontalFlip(), # Applied to val too! +# transforms.ToTensor(), +# ]) +``` + +## Storage Format Selection + +### Format Comparison and Decision Guide + +``` +Choosing a storage format? +├── Tabular metadata (labels, splits, paths) +│ └── Parquet — columnar, compressed, fast filtering with Polars/DuckDB +├── Streaming large image datasets +│ ├── WebDataset (.tar shards) — best for distributed training +│ └── TFRecord — TensorFlow ecosystem, sequential reads +├── Random-access image datasets +│ └── LMDB — memory-mapped, zero-copy reads, fast random access +├── In-memory analytics / interchange +│ └── Apache Arrow IPC — zero-copy, language-agnostic +└── Small datasets (< 1 GB) + └── Image folders + Parquet manifest — simplest, debuggable +``` + +### Writing Parquet with Polars + +```python +"""Storage utilities for writing datasets in optimized formats.""" + +from __future__ import annotations + +from pathlib import Path + +import polars as pl +from loguru import logger + + +def write_parquet_partitioned( + df: pl.DataFrame, + output_dir: Path, + partition_by: str = "split", +) -> None: + """Write Parquet partitioned by split for efficient filtering.""" + for partition_value in df[partition_by].unique().sort().to_list(): + partition_df = df.filter(pl.col(partition_by) == partition_value) + partition_path = output_dir / f"{partition_by}={partition_value}" / "data.parquet" + partition_path.parent.mkdir(parents=True, exist_ok=True) + partition_df.write_parquet( + partition_path, + compression="zstd", + row_group_size=10_000, + ) + logger.info( + "Wrote partition {}={}: {} rows", + partition_by, partition_value, len(partition_df), + ) +``` + +### LMDB for Random-Access Image Datasets + +```python +"""LMDB dataset for fast random-access image loading.""" + +from __future__ import annotations + +import io +import pickle +from pathlib import Path + +import lmdb +from PIL import Image +from loguru import logger + + +def build_lmdb_dataset(image_paths: list[Path], output_path: Path) -> None: + """Pack images into an LMDB database for fast random access.""" + map_size = 1024 * 1024 * 1024 * 50 # 50 GB max + env = lmdb.open(str(output_path), map_size=map_size) + + with env.begin(write=True) as txn: + for idx, img_path in enumerate(image_paths): + img_bytes = img_path.read_bytes() + txn.put(f"{idx:08d}".encode(), img_bytes) + + txn.put(b"__len__", str(len(image_paths)).encode()) + + logger.info("Built LMDB dataset: {} images at {}", len(image_paths), output_path) + env.close() + + +def read_lmdb_image(env: lmdb.Environment, index: int) -> Image.Image: + """Read a single image from LMDB by index.""" + with env.begin(buffers=True) as txn: + img_bytes = txn.get(f"{index:08d}".encode()) + if img_bytes is None: + msg = f"Image at index {index} not found in LMDB" + raise KeyError(msg) + return Image.open(io.BytesIO(img_bytes)) +``` + +## Data Quality Monitoring + +### Continuous Quality Checks + +```python +"""Data quality monitoring for production pipelines.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field +from loguru import logger +import polars as pl + + +class QualityReport(BaseModel): + """Summary of data quality checks.""" + total_records: int = Field(ge=0) + null_count: dict[str, int] + duplicate_count: int = Field(ge=0) + class_distribution: dict[str, int] + min_image_size: tuple[int, int] + max_image_size: tuple[int, int] + corrupted_files: list[str] + passed: bool + + +def run_quality_checks(df: pl.DataFrame) -> QualityReport: + """Run comprehensive quality checks on a dataset manifest.""" + null_counts = {col: df[col].null_count() for col in df.columns} + duplicates = df["file_hash"].is_duplicated().sum() + class_dist = dict( + df.group_by("label").len().sort("label") + .select(["label", "len"]) + .iter_rows() + ) + corrupted = df.filter( + (pl.col("width") < 32) | (pl.col("height") < 32) + )["relative_path"].to_list() + + report = QualityReport( + total_records=len(df), + null_count=null_counts, + duplicate_count=duplicates, + class_distribution=class_dist, + min_image_size=(df["width"].min(), df["height"].min()), + max_image_size=(df["width"].max(), df["height"].max()), + corrupted_files=corrupted, + passed=duplicates == 0 and len(corrupted) == 0 and all(v == 0 for v in null_counts.values()), + ) + + if report.passed: + logger.info("Quality checks PASSED: {} records", report.total_records) + else: + logger.error("Quality checks FAILED: {} duplicates, {} corrupted", + report.duplicate_count, len(report.corrupted_files)) + + return report +``` + +## Large-Scale Data Processing Patterns + +### Chunked Processing with Progress + +```python +"""Large-scale data processing utilities.""" + +from __future__ import annotations + +from pathlib import Path +from concurrent.futures import ProcessPoolExecutor, as_completed + +from pydantic import BaseModel, Field +from loguru import logger + + +class ProcessingConfig(BaseModel): + """Configuration for large-scale processing.""" + num_workers: int = Field(ge=1, le=128, default=8) + chunk_size: int = Field(ge=1, default=1000) + max_retries: int = Field(ge=0, default=3) + timeout_seconds: int = Field(ge=1, default=300) + + +def process_in_chunks( + file_paths: list[Path], + process_fn, + config: ProcessingConfig, +) -> list: + """Process files in parallel chunks with error handling.""" + results = [] + total = len(file_paths) + + with ProcessPoolExecutor(max_workers=config.num_workers) as executor: + for chunk_start in range(0, total, config.chunk_size): + chunk = file_paths[chunk_start : chunk_start + config.chunk_size] + futures = {executor.submit(process_fn, p): p for p in chunk} + + for future in as_completed(futures): + path = futures[future] + try: + result = future.result(timeout=config.timeout_seconds) + results.append(result) + except Exception: + logger.error("Failed to process: {}", path) + + logger.info( + "Progress: {}/{} ({:.1f}%)", + min(chunk_start + config.chunk_size, total), + total, + min(chunk_start + config.chunk_size, total) / total * 100, + ) + + logger.info("Processed {}/{} files successfully", len(results), total) + return results + + +# WRONG: Single-threaded, no error handling, no progress +# for f in all_files: +# process(f) +``` + +### Memory-Efficient Streaming + +```python +"""Memory-efficient data streaming for datasets that exceed RAM.""" + +from __future__ import annotations + +from pathlib import Path + +import polars as pl +from loguru import logger + + +def stream_large_parquet( + parquet_path: Path, + batch_size: int = 10_000, +) -> None: + """Process a large Parquet file in streaming fashion.""" + reader = pl.scan_parquet(parquet_path) + total_rows = reader.select(pl.len()).collect().item() + + logger.info("Streaming {} rows from {}", total_rows, parquet_path) + + for offset in range(0, total_rows, batch_size): + batch = reader.slice(offset, batch_size).collect() + # Process batch without loading full dataset into memory + process_batch(batch) + logger.debug("Processed rows {}-{}", offset, offset + len(batch)) +``` + +## Schema Evolution and Migration + +### Versioned Schemas with Pydantic + +```python +"""Schema evolution and migration for dataset formats.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field +from loguru import logger + + +class SchemaV1(BaseModel): + """Original schema — image path and label only.""" + image_path: str + label: str + + +class SchemaV2(BaseModel): + """V2 — added dimensions and hash for integrity.""" + image_path: str + label: str + width: int = Field(ge=1) + height: int = Field(ge=1) + file_hash: str + + +class SchemaV3(BaseModel): + """V3 — added split assignment and quality score.""" + image_path: str + label: str + width: int = Field(ge=1) + height: int = Field(ge=1) + file_hash: str + split: str = Field(pattern=r"^(train|val|test)$") + quality_score: float = Field(ge=0.0, le=1.0, default=1.0) + + +def migrate_v1_to_v2(record: SchemaV1, width: int, height: int, file_hash: str) -> SchemaV2: + """Migrate a V1 record to V2 by enriching with dimensions and hash.""" + return SchemaV2( + image_path=record.image_path, + label=record.label, + width=width, + height=height, + file_hash=file_hash, + ) + + +def migrate_v2_to_v3( + record: SchemaV2, + split: str, + quality_score: float = 1.0, +) -> SchemaV3: + """Migrate a V2 record to V3 by adding split and quality score.""" + return SchemaV3( + **record.model_dump(), + split=split, + quality_score=quality_score, + ) + + +# WRONG: Silently adding columns without versioning +# df["new_column"] = default_value # Which version is this? +``` + +### Schema Registry Pattern + +```python +"""Schema registry for tracking dataset format versions.""" + +from __future__ import annotations + +from pydantic import BaseModel +from loguru import logger + + +SCHEMA_REGISTRY: dict[str, type[BaseModel]] = { + "1.0.0": SchemaV1, + "2.0.0": SchemaV2, + "3.0.0": SchemaV3, +} + +MIGRATION_CHAIN: list[tuple[str, str]] = [ + ("1.0.0", "2.0.0"), + ("2.0.0", "3.0.0"), +] + + +def get_schema(version: str) -> type[BaseModel]: + """Look up a schema by version.""" + if version not in SCHEMA_REGISTRY: + msg = f"Unknown schema version: {version}. Known: {list(SCHEMA_REGISTRY.keys())}" + raise ValueError(msg) + return SCHEMA_REGISTRY[version] + + +def get_migration_path(from_version: str, to_version: str) -> list[tuple[str, str]]: + """Compute the migration steps from one version to another.""" + path = [] + current = from_version + for src, dst in MIGRATION_CHAIN: + if src == current: + path.append((src, dst)) + current = dst + if current == to_version: + break + if current != to_version: + msg = f"No migration path from {from_version} to {to_version}" + raise ValueError(msg) + logger.info("Migration path: {}", " -> ".join([from_version] + [dst for _, dst in path])) + return path +``` + +## Anti-Patterns + +- **Never modify raw data in place.** Always write transformations to a separate directory and keep originals intact. +- **Never split randomly without stratification.** Class imbalance in splits causes unreliable metrics. +- **Never ignore group/subject leakage.** Images from the same patient, video, or scene must be in the same split. +- **Never skip validation between pipeline stages.** Catching corrupt data early saves hours of wasted training time. +- **Never use CSV for large datasets.** Use Parquet for columnar data, LMDB for random-access images, or WebDataset for streaming. +- **Never hard-code file paths.** Use Pydantic config objects with validated path fields. +- **Never version data in git.** Use DVC, cloud object storage, or a data registry for datasets. +- **Never assume data is clean.** Always run quality checks, even on "curated" public datasets. +- **Never apply augmentations to validation or test sets.** Only deterministic transforms (resize, normalize) are allowed outside training. +- **Never silently change schemas.** Version your schemas and provide explicit migration functions. + +## Integration with Other Skills + +- **DVC** — Data versioning and pipeline reproducibility for tracking dataset lineage. +- **Pydantic Strict** — Validated configuration and schema definitions for every pipeline stage. +- **Testing** — Unit tests for data transforms, integration tests for pipeline stages, property-based tests for augmentations. +- **PyTorch Lightning** — DataModule integration to feed validated, split datasets into training. +- **Polars/Pandas** — DataFrame operations for metadata processing and manifest management. +- **Docker CV** — Containerizing data pipelines for reproducible batch processing. diff --git a/agents/data-engineer/agent.toml b/agents/data-engineer/agent.toml new file mode 100644 index 0000000..76cf710 --- /dev/null +++ b/agents/data-engineer/agent.toml @@ -0,0 +1,8 @@ +[agent] +name = "data-engineer" +version = "1.0.0" +type = "advisory" +tags = ["data", "pipeline", "etl", "quality", "engineering"] + +[ci] +has_action = false diff --git a/agents/devops-infra/README.md b/agents/devops-infra/README.md new file mode 100644 index 0000000..dfc2b68 --- /dev/null +++ b/agents/devops-infra/README.md @@ -0,0 +1,41 @@ +# DevOps/Infrastructure Agent + +The DevOps/Infrastructure Agent guides all infrastructure and deployment decisions for ML/CV projects, from containerization to production orchestration. + +## Purpose + +This agent provides expert advice on packaging, deploying, and operating ML services in production. It covers Docker multi-stage builds, Kubernetes deployments with GPU support, CI/CD pipeline design, monitoring and observability, infrastructure as code, and security hardening. + +## Strictness Level + +**ADVISORY** — This agent guides architectural decisions but does not block. + +## When to Use + +- Containerizing an ML model for deployment (Docker, multi-stage builds). +- Choosing between deployment targets (Cloud Run, ECS, Kubernetes, serverless). +- Designing CI/CD pipelines for ML projects (test, build, deploy). +- Setting up monitoring and alerting for model serving (Prometheus, Grafana). +- Writing infrastructure as code (Terraform, Kubernetes manifests). +- Reviewing security posture of ML infrastructure. + +## Example Session + +``` +You: "I need to deploy my PyTorch model as an API with GPU support" + +DevOps Agent: "I recommend this architecture: +1. Multi-stage Dockerfile with NVIDIA base image +2. FastAPI serving layer with health checks +3. Cloud Run with GPU (for auto-scaling) or K8s + GPU nodes (for high throughput) +4. Prometheus metrics for inference latency tracking +5. GitHub Actions pipeline: test → build → push → deploy" +``` + +## Related Skills + +- `docker-cv` — Detailed Docker patterns for CV workloads +- `github-actions` — CI/CD workflow definitions +- `gcp` — Google Cloud Platform deployment patterns +- `aws-sagemaker` — AWS managed ML infrastructure +- `fastapi` — The application framework behind the deployed services diff --git a/agents/devops-infra/SKILL.md b/agents/devops-infra/SKILL.md new file mode 100644 index 0000000..0fd19ec --- /dev/null +++ b/agents/devops-infra/SKILL.md @@ -0,0 +1,499 @@ +--- +name: devops-infra +description: > + DevOps and infrastructure advisory agent for ML projects. Guides Docker + containerization, Kubernetes deployment, CI/CD pipeline design, cloud + architecture decisions, monitoring, and infrastructure-as-code patterns. +--- + +# DevOps/Infrastructure Agent + +You are a DevOps and Infrastructure Agent specializing in production deployment and operational excellence for ML/CV projects. You guide architectural decisions for containerization, orchestration, CI/CD, and cloud infrastructure. + +## Core Principles + +1. **Infrastructure as Code:** All infrastructure is defined in version-controlled configuration files — never manually configure resources through UIs. +2. **Immutable Deployments:** Build once, deploy anywhere. Container images are tagged with content hashes, never mutated after build. +3. **Shift Left Security:** Security scanning, dependency auditing, and secrets detection happen in CI, not after deployment. +4. **Observable by Default:** Every service ships with health checks, structured logging, metrics endpoints, and distributed tracing hooks. +5. **Cost Awareness:** Right-size compute instances, use spot/preemptible for training, set up resource quotas and cost alerts. + +## Decision Framework + +When the developer asks about infrastructure, follow this decision tree: + +### Containerization Decisions + +``` +Need to package an ML application? +├── Single model serving → Dockerfile with multi-stage build +├── Multiple models/services → Docker Compose for local, K8s for prod +├── GPU required? +│ ├── Training → Use NVIDIA base images (nvcr.io/nvidia/pytorch) +│ └── Inference → Use optimized runtime images (NVIDIA Triton, TorchServe) +└── No GPU → Python slim base image +``` + +### Deployment Decisions + +``` +Where should this run? +├── Internal/team use → Single instance + Docker Compose +├── Production API (< 100 RPS) → Cloud Run / App Runner / ECS Fargate +├── Production API (> 100 RPS) → Kubernetes with autoscaling +├── Batch inference → SageMaker Batch Transform / Vertex AI Batch +└── Training at scale → SageMaker / Vertex AI Training Jobs +``` + +### CI/CD Pipeline Decisions + +``` +What should CI do? +├── Every commit → lint + type check + unit tests (< 5 min) +├── Every PR → above + integration tests + Docker build (< 15 min) +├── Merge to main → above + push image + deploy staging +└── Release tag → deploy production + create GitHub Release +``` + +## Docker Patterns + +### Multi-Stage Build for ML Services + +```dockerfile +# Build stage — install dependencies +FROM python:3.11-slim AS builder + +WORKDIR /app +RUN pip install --no-cache-dir uv + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +# Runtime stage — minimal image +FROM python:3.11-slim AS runtime + +WORKDIR /app + +# Copy only the virtual environment from builder +COPY --from=builder /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:$PATH" + +# Copy application code +COPY src/ ./src/ +COPY models/ ./models/ + +# Non-root user for security +RUN useradd --create-home appuser +USER appuser + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + +CMD ["uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] +``` + +### GPU Training Container + +```dockerfile +FROM nvcr.io/nvidia/pytorch:24.01-py3 + +WORKDIR /workspace + +# Install project dependencies +COPY pyproject.toml uv.lock ./ +RUN pip install uv && uv sync --frozen --no-dev + +COPY src/ ./src/ +COPY configs/ ./configs/ + +ENTRYPOINT ["python", "-m", "src.train"] +``` + +### Docker Compose for Local Development + +```yaml +# docker-compose.yml +services: + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "8000:8000" + volumes: + - ./models:/app/models:ro + environment: + - MODEL_PATH=/app/models/best.onnx + - LOG_LEVEL=debug + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - ./monitoring/dashboards:/var/lib/grafana/dashboards:ro +``` + +## Kubernetes Patterns + +### Deployment Manifest for ML Services + +```yaml +# k8s/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: model-serving + labels: + app: model-serving +spec: + replicas: 2 + selector: + matchLabels: + app: model-serving + template: + metadata: + labels: + app: model-serving + spec: + containers: + - name: api + image: registry.example.com/model-serving:v1.2.3 + ports: + - containerPort: 8000 + resources: + requests: + cpu: "1" + memory: "2Gi" + nvidia.com/gpu: "1" + limits: + cpu: "2" + memory: "4Gi" + nvidia.com/gpu: "1" + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /ready + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 5 + env: + - name: MODEL_PATH + value: /models/best.onnx + - name: LOG_LEVEL + valueFrom: + configMapKeyRef: + name: model-config + key: log_level + volumeMounts: + - name: models + mountPath: /models + readOnly: true + volumes: + - name: models + persistentVolumeClaim: + claimName: model-storage +``` + +### Horizontal Pod Autoscaler + +```yaml +# k8s/hpa.yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: model-serving-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: model-serving + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Pods + pods: + metric: + name: inference_latency_p99 + target: + type: AverageValue + averageValue: "200m" +``` + +## CI/CD Patterns + +### GitHub Actions for ML Projects + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint + run: uv run ruff check . + + - name: Format check + run: uv run ruff format --check . + + - name: Type check + run: uv run mypy src/ --strict + + - name: Unit tests + run: uv run pytest tests/ -v --cov=src --cov-report=xml + + docker: + needs: quality + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ghcr.io/${{ github.repository }}:latest + ghcr.io/${{ github.repository }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max +``` + +### Model Deployment Pipeline + +```yaml +# .github/workflows/deploy.yml +name: Deploy Model + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloud Run + uses: google-github-actions/deploy-cloudrun@v2 + with: + service: model-serving + image: ghcr.io/${{ github.repository }}:${{ github.event.release.tag_name }} + region: us-central1 + flags: | + --cpu=2 + --memory=4Gi + --gpu=1 + --gpu-type=nvidia-l4 + --min-instances=1 + --max-instances=10 + --concurrency=80 +``` + +## Monitoring and Observability + +### Prometheus Metrics for ML Services + +```python +"""Prometheus metrics for ML model serving.""" + +from __future__ import annotations + +import time +from functools import wraps + +from prometheus_client import Counter, Histogram, Gauge, Info + + +# Standard ML serving metrics +PREDICTION_COUNT = Counter( + "ml_predictions_total", + "Total number of predictions", + ["model_name", "status"], +) + +PREDICTION_LATENCY = Histogram( + "ml_prediction_duration_seconds", + "Prediction latency in seconds", + ["model_name"], + buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0], +) + +MODEL_INFO = Info( + "ml_model", + "Model metadata", +) + +GPU_UTILIZATION = Gauge( + "ml_gpu_utilization_percent", + "GPU utilization percentage", + ["gpu_id"], +) + +BATCH_SIZE = Histogram( + "ml_batch_size", + "Batch sizes received", + ["model_name"], + buckets=[1, 2, 4, 8, 16, 32, 64, 128], +) +``` + +### Structured Logging Configuration + +```python +"""Structured logging setup for production ML services.""" + +from __future__ import annotations + +import sys + +from loguru import logger + + +def setup_production_logging(service_name: str, log_level: str = "INFO") -> None: + """Configure structured JSON logging for production.""" + logger.remove() + + logger.add( + sys.stdout, + format="{message}", + level=log_level, + serialize=True, # JSON output + ) + + logger.bind(service=service_name) +``` + +## Infrastructure as Code + +### Terraform for ML Infrastructure + +```hcl +# terraform/main.tf — GCP ML infrastructure + +resource "google_cloud_run_v2_service" "model_serving" { + name = "model-serving" + location = var.region + + template { + containers { + image = var.container_image + + resources { + limits = { + cpu = "2" + memory = "4Gi" + "nvidia.com/gpu" = "1" + } + } + + ports { + container_port = 8000 + } + + liveness_probe { + http_get { + path = "/health" + } + initial_delay_seconds = 30 + period_seconds = 10 + } + } + + scaling { + min_instance_count = 1 + max_instance_count = 10 + } + } +} +``` + +## Security Checklist + +When reviewing infrastructure, verify these items: + +1. **No secrets in code or images** — use environment variables or secret managers. +2. **Non-root container user** — always add `USER appuser` in Dockerfiles. +3. **Minimal base images** — use `-slim` variants, scan with Trivy or Grype. +4. **Network policies** — restrict pod-to-pod communication in Kubernetes. +5. **Resource limits** — always set CPU/memory limits to prevent noisy neighbor issues. +6. **Image pinning** — use SHA digests for base images in production Dockerfiles. +7. **Supply chain security** — sign images, verify checksums, use lock files. + +## Anti-Patterns + +- **Never use `latest` tags in production** — always pin versions with content hashes or semver. +- **Never store model weights in container images** — mount from persistent storage or object storage. +- **Never run containers as root** — create dedicated non-root users. +- **Never skip health checks** — both liveness and readiness probes are required. +- **Never deploy without resource limits** — unbounded containers risk node instability. +- **Never hardcode credentials** — use IAM roles, service accounts, or secret managers. +- **Never skip CI on infrastructure changes** — Terraform plans and Kubernetes manifests need validation too. + +## Integration with Other Skills + +- **Docker CV** — detailed Docker patterns for CV-specific workloads with CUDA and GPU support. +- **GitHub Actions** — CI/CD workflow patterns for testing and deployment. +- **GCP** — Google Cloud-specific deployment patterns with Vertex AI and Cloud Run. +- **AWS SageMaker** — AWS-specific training and deployment infrastructure. +- **FastAPI** — the application framework deployed inside the containers this agent configures. diff --git a/agents/devops-infra/agent.toml b/agents/devops-infra/agent.toml new file mode 100644 index 0000000..85d1048 --- /dev/null +++ b/agents/devops-infra/agent.toml @@ -0,0 +1,8 @@ +[agent] +name = "devops-infra" +version = "1.0.0" +type = "advisory" +tags = ["devops", "infrastructure", "docker", "kubernetes", "cicd", "cloud"] + +[ci] +has_action = false diff --git a/agents/expert-coder/agent.toml b/agents/expert-coder/agent.toml new file mode 100644 index 0000000..39e7cce --- /dev/null +++ b/agents/expert-coder/agent.toml @@ -0,0 +1,8 @@ +[agent] +name = "expert-coder" +version = "1.0.0" +type = "advisory" +tags = ["coding", "architecture", "python", "best-practices"] + +[ci] +has_action = false diff --git a/agents/ml-engineer/agent.toml b/agents/ml-engineer/agent.toml new file mode 100644 index 0000000..d6d4337 --- /dev/null +++ b/agents/ml-engineer/agent.toml @@ -0,0 +1,8 @@ +[agent] +name = "ml-engineer" +version = "1.0.0" +type = "advisory" +tags = ["machine-learning", "training", "architecture", "optimization"] + +[ci] +has_action = false diff --git a/agents/test-engineer/agent.toml b/agents/test-engineer/agent.toml new file mode 100644 index 0000000..030ecbb --- /dev/null +++ b/agents/test-engineer/agent.toml @@ -0,0 +1,8 @@ +[agent] +name = "test-engineer" +version = "1.0.0" +type = "blocking" +tags = ["testing", "coverage", "ci-cd", "enforcement"] + +[ci] +has_action = true diff --git a/archetypes/cv-inference-service/archetype.toml b/archetypes/cv-inference-service/archetype.toml new file mode 100644 index 0000000..d3fadc3 --- /dev/null +++ b/archetypes/cv-inference-service/archetype.toml @@ -0,0 +1,10 @@ +[archetype] +name = "cv-inference-service" +version = "1.0.0" +category = "cv-ml" +tags = ["inference", "deployment", "fastapi", "onnx"] +description = "FastAPI + ONNX deployment service for computer vision models" + +[skills] +required = ["onnx", "pydantic-strict", "loguru", "docker-cv", "testing"] +recommended = ["tensorrt", "gcp", "github-actions"] diff --git a/archetypes/cv-inference-service/template/Dockerfile b/archetypes/cv-inference-service/template/Dockerfile new file mode 100644 index 0000000..c0cac9b --- /dev/null +++ b/archetypes/cv-inference-service/template/Dockerfile @@ -0,0 +1,21 @@ +FROM python:${python_version}-slim AS builder + +WORKDIR /app +RUN pip install --no-cache-dir uv + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +FROM python:${python_version}-slim AS runtime + +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:$$PATH" + +COPY src/ ./src/ + +RUN useradd --create-home appuser +USER appuser + +EXPOSE 8000 +CMD ["uvicorn", "${package_name}.app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/archetypes/cv-inference-service/template/README.md b/archetypes/cv-inference-service/template/README.md new file mode 100644 index 0000000..9563d04 --- /dev/null +++ b/archetypes/cv-inference-service/template/README.md @@ -0,0 +1,32 @@ +# ${project_name} + +${description} + +## Setup + +```bash +uv sync --all-extras +``` + +## Running + +```bash +# Development server +uv run uvicorn ${package_name}.app:app --reload --port 8000 + +# Production +uv run uvicorn ${package_name}.app:app --host 0.0.0.0 --port 8000 --workers 4 +``` + +## API Endpoints + +- `GET /health` — Health check +- `POST /api/v1/predict` — Run inference on an image + +## Development + +```bash +uv run pytest tests/ -v +uv run ruff check . +uv run mypy src/ --strict +``` diff --git a/archetypes/cv-inference-service/template/pyproject.toml b/archetypes/cv-inference-service/template/pyproject.toml new file mode 100644 index 0000000..b0c3718 --- /dev/null +++ b/archetypes/cv-inference-service/template/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "${project_slug}" +version = "0.1.0" +description = "${description}" +authors = [{name = "${author}"}] +requires-python = ">=${python_version}" +dependencies = [ + "fastapi>=0.100", + "uvicorn[standard]>=0.20", + "pydantic>=2.0", + "loguru>=0.7", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pytest-cov>=4.1", + "httpx>=0.24", + "ruff>=0.8", + "mypy>=1.11", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/${package_name}"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "UP", "S", "B", "A", "C4", "T20", "SIM"] +ignore = ["S101"] + +[tool.mypy] +python_version = "${python_version}" +strict = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v" diff --git a/archetypes/cv-inference-service/template/src/${package_name}/__init__.py b/archetypes/cv-inference-service/template/src/${package_name}/__init__.py new file mode 100644 index 0000000..001d01f --- /dev/null +++ b/archetypes/cv-inference-service/template/src/${package_name}/__init__.py @@ -0,0 +1,3 @@ +"""${project_name} — inference service.""" + +__version__ = "0.1.0" diff --git a/archetypes/cv-inference-service/template/src/${package_name}/app.py b/archetypes/cv-inference-service/template/src/${package_name}/app.py new file mode 100644 index 0000000..4ec5b27 --- /dev/null +++ b/archetypes/cv-inference-service/template/src/${package_name}/app.py @@ -0,0 +1,41 @@ +"""FastAPI application for ${project_name}.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from loguru import logger + +from .schemas import HealthResponse + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + """Load model on startup, release on shutdown.""" + logger.info("Loading model...") + # TODO: Load your model here + yield + logger.info("Shutting down") + + +app = FastAPI( + title="${project_name}", + version="0.1.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + """Health check endpoint.""" + return HealthResponse(status="healthy", version="0.1.0") diff --git a/archetypes/cv-inference-service/template/src/${package_name}/schemas.py b/archetypes/cv-inference-service/template/src/${package_name}/schemas.py new file mode 100644 index 0000000..c63eb53 --- /dev/null +++ b/archetypes/cv-inference-service/template/src/${package_name}/schemas.py @@ -0,0 +1,34 @@ +"""Pydantic schemas for ${project_name}.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class HealthResponse(BaseModel, frozen=True): + """Health check response.""" + + status: str + version: str + + +class PredictionRequest(BaseModel, frozen=True): + """Prediction request.""" + + image_b64: str = Field(..., description="Base64-encoded image") + confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0) + + +class Detection(BaseModel, frozen=True): + """Single detection result.""" + + label: str + confidence: float = Field(ge=0.0, le=1.0) + bbox: list[float] = Field(min_length=4, max_length=4) + + +class PredictionResponse(BaseModel, frozen=True): + """Prediction response.""" + + detections: list[Detection] + inference_time_ms: float diff --git a/archetypes/cv-inference-service/template/tests/__init__.py b/archetypes/cv-inference-service/template/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/archetypes/cv-inference-service/template/tests/test_app.py b/archetypes/cv-inference-service/template/tests/test_app.py new file mode 100644 index 0000000..7683309 --- /dev/null +++ b/archetypes/cv-inference-service/template/tests/test_app.py @@ -0,0 +1,22 @@ +"""Tests for the inference service.""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from ${package_name}.app import app + + +@pytest.fixture +async def client() -> AsyncClient: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest.mark.anyio +async def test_health(client: AsyncClient) -> None: + response = await client.get("/health") + assert response.status_code == 200 + assert response.json()["status"] == "healthy" diff --git a/archetypes/data-processing-pipeline/archetype.toml b/archetypes/data-processing-pipeline/archetype.toml new file mode 100644 index 0000000..fdb552c --- /dev/null +++ b/archetypes/data-processing-pipeline/archetype.toml @@ -0,0 +1,10 @@ +[archetype] +name = "data-processing-pipeline" +version = "1.0.0" +category = "cv-ml" +tags = ["data", "etl", "pipeline", "processing"] +description = "ETL workflows for dataset processing and preparation" + +[skills] +required = ["pydantic-strict", "loguru", "testing", "dvc"] +recommended = ["docker-cv", "gcp", "github-actions"] diff --git a/archetypes/library-package/archetype.toml b/archetypes/library-package/archetype.toml new file mode 100644 index 0000000..fe263e6 --- /dev/null +++ b/archetypes/library-package/archetype.toml @@ -0,0 +1,10 @@ +[archetype] +name = "library-package" +version = "1.0.0" +category = "core" +tags = ["package", "library", "pypi", "reusable"] +description = "Reusable Python package for PyPI distribution" + +[skills] +required = ["pydantic-strict", "code-quality", "testing", "pypi", "loguru"] +recommended = ["github-actions", "pre-commit"] diff --git a/archetypes/model-zoo/archetype.toml b/archetypes/model-zoo/archetype.toml new file mode 100644 index 0000000..37ded93 --- /dev/null +++ b/archetypes/model-zoo/archetype.toml @@ -0,0 +1,10 @@ +[archetype] +name = "model-zoo" +version = "1.0.0" +category = "cv-ml" +tags = ["models", "pretrained", "collection", "benchmarks"] +description = "Collection of pretrained models with benchmarking and evaluation" + +[skills] +required = ["pytorch-lightning", "onnx", "pydantic-strict", "loguru", "testing"] +recommended = ["dvc", "wandb", "docker-cv"] diff --git a/archetypes/pytorch-training-project/archetype.toml b/archetypes/pytorch-training-project/archetype.toml new file mode 100644 index 0000000..ab711e9 --- /dev/null +++ b/archetypes/pytorch-training-project/archetype.toml @@ -0,0 +1,10 @@ +[archetype] +name = "pytorch-training-project" +version = "1.0.0" +category = "cv-ml" +tags = ["training", "pytorch", "lightning", "deep-learning"] +description = "Model training with PyTorch Lightning, Hydra config, and experiment tracking" + +[skills] +required = ["pytorch-lightning", "hydra-config", "pydantic-strict", "loguru", "testing"] +recommended = ["wandb", "docker-cv", "github-actions"] diff --git a/archetypes/pytorch-training-project/template/README.md b/archetypes/pytorch-training-project/template/README.md new file mode 100644 index 0000000..e254930 --- /dev/null +++ b/archetypes/pytorch-training-project/template/README.md @@ -0,0 +1,51 @@ +# ${project_name} + +${description} + +## Setup + +```bash +uv sync --all-extras +``` + +## Training + +```bash +# Default training +uv run python -m ${package_name}.train + +# With overrides +uv run python -m ${package_name}.train model=resnet50 trainer.max_epochs=50 + +# Debug run (1 batch) +uv run python -m ${package_name}.train trainer=debug +``` + +## Development + +```bash +uv run pytest tests/ -v +uv run ruff check . +uv run ruff format . +uv run mypy src/ --strict +``` + +## Project Structure + +``` +${project_slug}/ +├── src/${package_name}/ +│ ├── __init__.py +│ ├── model.py # LightningModule +│ ├── data.py # LightningDataModule +│ ├── train.py # Training entry point +│ └── transforms.py # Data augmentations +├── configs/ +│ ├── config.yaml # Main Hydra config +│ ├── model/ +│ ├── data/ +│ └── trainer/ +├── tests/ +├── pyproject.toml +└── README.md +``` diff --git a/archetypes/pytorch-training-project/template/configs/config.yaml b/archetypes/pytorch-training-project/template/configs/config.yaml new file mode 100644 index 0000000..aeb06ba --- /dev/null +++ b/archetypes/pytorch-training-project/template/configs/config.yaml @@ -0,0 +1,6 @@ +defaults: + - model: default + - data: default + - trainer: default + +seed: 42 diff --git a/archetypes/pytorch-training-project/template/configs/model/default.yaml b/archetypes/pytorch-training-project/template/configs/model/default.yaml new file mode 100644 index 0000000..6075077 --- /dev/null +++ b/archetypes/pytorch-training-project/template/configs/model/default.yaml @@ -0,0 +1,4 @@ +num_classes: 10 +learning_rate: 1e-3 +backbone: resnet18 +pretrained: true diff --git a/archetypes/pytorch-training-project/template/configs/trainer/default.yaml b/archetypes/pytorch-training-project/template/configs/trainer/default.yaml new file mode 100644 index 0000000..5bcc510 --- /dev/null +++ b/archetypes/pytorch-training-project/template/configs/trainer/default.yaml @@ -0,0 +1,4 @@ +max_epochs: 50 +accelerator: auto +precision: 16-mixed +log_every_n_steps: 10 diff --git a/archetypes/pytorch-training-project/template/pyproject.toml b/archetypes/pytorch-training-project/template/pyproject.toml new file mode 100644 index 0000000..b0b35d6 --- /dev/null +++ b/archetypes/pytorch-training-project/template/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "${project_slug}" +version = "0.1.0" +description = "${description}" +authors = [{name = "${author}"}] +requires-python = ">=${python_version}" + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pytest-cov>=4.1", + "ruff>=0.8", + "mypy>=1.11", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/${package_name}"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "UP", "S", "B", "A", "C4", "T20", "SIM"] +ignore = ["S101"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "T201"] + +[tool.mypy] +python_version = "${python_version}" +strict = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v" diff --git a/archetypes/pytorch-training-project/template/src/${package_name}/__init__.py b/archetypes/pytorch-training-project/template/src/${package_name}/__init__.py new file mode 100644 index 0000000..0f3e37b --- /dev/null +++ b/archetypes/pytorch-training-project/template/src/${package_name}/__init__.py @@ -0,0 +1,3 @@ +"""${project_name} — training pipeline.""" + +__version__ = "0.1.0" diff --git a/archetypes/pytorch-training-project/template/src/${package_name}/data.py b/archetypes/pytorch-training-project/template/src/${package_name}/data.py new file mode 100644 index 0000000..ce0822b --- /dev/null +++ b/archetypes/pytorch-training-project/template/src/${package_name}/data.py @@ -0,0 +1,74 @@ +"""LightningDataModule for ${project_name}.""" + +from __future__ import annotations + +from pathlib import Path + +import lightning as L +from pydantic import BaseModel, Field +from torch.utils.data import DataLoader + + +class DataConfig(BaseModel, frozen=True): + """Data configuration.""" + + data_dir: str = "data" + batch_size: int = 32 + num_workers: int = 4 + num_classes: int = 10 + image_size: int = 224 + + +class ImageDataModule(L.LightningDataModule): + """Image dataset module. + + Args: + config: Data configuration. + """ + + def __init__(self, config: DataConfig) -> None: + super().__init__() + self.save_hyperparameters() + self.config = config + + def setup(self, stage: str | None = None) -> None: + """Set up datasets for each stage.""" + # Replace with your dataset loading logic + from torchvision import datasets, transforms + + transform = transforms.Compose([ + transforms.Resize((self.config.image_size, self.config.image_size)), + transforms.ToTensor(), + transforms.Normalize( + mean=[0.485, 0.456, 0.406], + std=[0.229, 0.224, 0.225], + ), + ]) + + if stage == "fit" or stage is None: + self.train_dataset = datasets.FakeData( + size=100, image_size=(3, self.config.image_size, self.config.image_size), + num_classes=self.config.num_classes, transform=transform, + ) + self.val_dataset = datasets.FakeData( + size=20, image_size=(3, self.config.image_size, self.config.image_size), + num_classes=self.config.num_classes, transform=transform, + ) + + def train_dataloader(self) -> DataLoader: + return DataLoader( + self.train_dataset, + batch_size=self.config.batch_size, + shuffle=True, + num_workers=self.config.num_workers, + pin_memory=True, + ) + + def val_dataloader(self) -> DataLoader: + return DataLoader( + self.val_dataset, + batch_size=self.config.batch_size, + shuffle=False, + num_workers=self.config.num_workers, + pin_memory=True, + ) diff --git a/archetypes/pytorch-training-project/template/src/${package_name}/model.py b/archetypes/pytorch-training-project/template/src/${package_name}/model.py new file mode 100644 index 0000000..f058870 --- /dev/null +++ b/archetypes/pytorch-training-project/template/src/${package_name}/model.py @@ -0,0 +1,82 @@ +"""LightningModule for ${project_name}.""" + +from __future__ import annotations + +from typing import Any + +import lightning as L +import torch +import torch.nn as nn +import torchmetrics +from pydantic import BaseModel, Field +from torch import Tensor + + +class ModelConfig(BaseModel, frozen=True): + """Model configuration.""" + + num_classes: int = 10 + learning_rate: float = 1e-3 + backbone: str = "resnet18" + pretrained: bool = True + + +class Classifier(L.LightningModule): + """Image classification model. + + Args: + config: Model configuration. + """ + + def __init__(self, config: ModelConfig) -> None: + super().__init__() + self.save_hyperparameters() + self.config = config + + self.backbone = self._build_backbone() + self.head = nn.LazyLinear(config.num_classes) + self.criterion = nn.CrossEntropyLoss() + self.train_acc = torchmetrics.Accuracy( + task="multiclass", num_classes=config.num_classes, + ) + self.val_acc = torchmetrics.Accuracy( + task="multiclass", num_classes=config.num_classes, + ) + + def _build_backbone(self) -> nn.Module: + """Build the backbone network.""" + import torchvision.models as models + + weights = "DEFAULT" if self.config.pretrained else None + model = getattr(models, self.config.backbone)(weights=weights) + # Remove the classification head + model.fc = nn.Identity() + return model + + def forward(self, x: Tensor) -> Tensor: + features = self.backbone(x) + return self.head(features) + + def training_step(self, batch: dict[str, Tensor], batch_idx: int) -> Tensor: + logits = self(batch["image"]) + loss = self.criterion(logits, batch["label"]) + self.train_acc(logits, batch["label"]) + self.log("train/loss", loss, prog_bar=True) + self.log("train/acc", self.train_acc, on_step=False, on_epoch=True) + return loss + + def validation_step(self, batch: dict[str, Tensor], batch_idx: int) -> None: + logits = self(batch["image"]) + loss = self.criterion(logits, batch["label"]) + self.val_acc(logits, batch["label"]) + self.log("val/loss", loss, prog_bar=True) + self.log("val/acc", self.val_acc, on_step=False, on_epoch=True) + + def configure_optimizers(self) -> dict[str, Any]: + optimizer = torch.optim.AdamW( + self.parameters(), lr=self.config.learning_rate, + ) + scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( + optimizer, T_max=self.trainer.max_epochs, + ) + return {"optimizer": optimizer, "lr_scheduler": scheduler} diff --git a/archetypes/pytorch-training-project/template/src/${package_name}/train.py b/archetypes/pytorch-training-project/template/src/${package_name}/train.py new file mode 100644 index 0000000..8b9e386 --- /dev/null +++ b/archetypes/pytorch-training-project/template/src/${package_name}/train.py @@ -0,0 +1,34 @@ +"""Training entry point for ${project_name}.""" + +from __future__ import annotations + +import lightning as L +from loguru import logger + + +def main() -> None: + """Run training.""" + from .data import DataConfig, ImageDataModule + from .model import Classifier, ModelConfig + + model_config = ModelConfig() + data_config = DataConfig() + + logger.info("Starting training: {}", model_config.backbone) + + model = Classifier(config=model_config) + datamodule = ImageDataModule(config=data_config) + + trainer = L.Trainer( + max_epochs=10, + accelerator="auto", + precision="16-mixed", + log_every_n_steps=10, + ) + + trainer.fit(model, datamodule=datamodule) + logger.info("Training complete") + + +if __name__ == "__main__": + main() diff --git a/archetypes/pytorch-training-project/template/tests/__init__.py b/archetypes/pytorch-training-project/template/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/archetypes/pytorch-training-project/template/tests/test_model.py b/archetypes/pytorch-training-project/template/tests/test_model.py new file mode 100644 index 0000000..693a2fa --- /dev/null +++ b/archetypes/pytorch-training-project/template/tests/test_model.py @@ -0,0 +1,18 @@ +"""Tests for the model module.""" + +from __future__ import annotations + +import torch + + +def test_model_forward_shape() -> None: + """Test that the model produces correct output shape.""" + from ${package_name}.model import Classifier, ModelConfig + + config = ModelConfig(num_classes=10, pretrained=False) + model = Classifier(config=config) + model.eval() + + batch = torch.randn(2, 3, 224, 224) + output = model(batch) + assert output.shape == (2, 10) diff --git a/archetypes/research-notebook/archetype.toml b/archetypes/research-notebook/archetype.toml new file mode 100644 index 0000000..f2a6263 --- /dev/null +++ b/archetypes/research-notebook/archetype.toml @@ -0,0 +1,10 @@ +[archetype] +name = "research-notebook" +version = "1.0.0" +category = "cv-ml" +tags = ["research", "jupyter", "experimentation", "prototyping"] +description = "Jupyter-based experimentation environment for ML research" + +[skills] +required = ["pytorch-lightning", "matplotlib", "loguru"] +recommended = ["hydra-config", "wandb", "opencv"] diff --git a/docs/agents/code-review.md b/docs/agents/code-review.md index 00d5256..4ab3a12 100644 --- a/docs/agents/code-review.md +++ b/docs/agents/code-review.md @@ -33,9 +33,9 @@ jobs: - uses: prefix-dev/setup-pixi@v0.8.1 with: pixi-version: latest - - run: pixi run ruff format --check . - - run: pixi run ruff check . - - run: pixi run mypy src/ + - run: uv run ruff format --check . + - run: uv run ruff check . + - run: uv run mypy src/ ``` ## Local Development @@ -44,13 +44,13 @@ Run checks locally before pushing to avoid CI failures: ```bash # Run all checks -pixi run lint +uv run lint # Auto-fix formatting -pixi run format +uv run format # Type check -pixi run typecheck +uv run typecheck ``` ## Pre-commit Integration @@ -68,7 +68,7 @@ Ruff lint................................Passed | Failure | Fix | |---------|-----| | `F401: imported but unused` | Remove unused import | -| `I001: import not sorted` | Run `pixi run format` | +| `I001: import not sorted` | Run `uv run format` | | `S101: assert used` | Only use assert in test files | | `T201: print found` | Use `logging` instead of `print` | | `mypy: Missing type hints` | Add type annotations to all functions | diff --git a/docs/agents/data-engineer.md b/docs/agents/data-engineer.md new file mode 100644 index 0000000..56cad7e --- /dev/null +++ b/docs/agents/data-engineer.md @@ -0,0 +1,45 @@ +# Data Engineer Agent + +The Data Engineer Agent guides data pipeline architecture, data quality, and storage decisions for ML/CV projects. + +**Agent directory:** `agents/data-engineer/` + +## Purpose + +This agent provides expert advice on building data pipelines for ML projects. It covers ETL/ELT patterns, data validation with Pydantic, dataset versioning with DVC, storage format selection, data augmentation pipeline design, and data quality monitoring. + +## Strictness Level + +**ADVISORY** — This agent guides decisions but does not block. + +## When to Use + +- Designing data pipelines for training data preparation +- Choosing storage formats (Parquet, Arrow, LMDB, TFRecord) +- Implementing data validation and quality checks +- Planning dataset versioning and lineage tracking +- Designing data augmentation strategies +- Handling schema evolution and migration + +## Example Session + +``` +You: "I need to build a data pipeline for image classification with 100K images" + +Data Engineer: "I recommend this approach: +1. Store raw images in S3/GCS, metadata in Parquet +2. DVC for dataset versioning with remote storage +3. Pydantic models for metadata validation +4. WebDataset or LMDB for training-time access +5. Great Expectations for automated quality checks" +``` + +## Related Skills + +- `dvc` — Data versioning patterns +- `pydantic-strict` — Data validation models +- `testing` — Data pipeline testing strategies + +## Full Reference + +See [`agents/data-engineer/SKILL.md`](https://github.com/ortizeg/whet/blob/main/agents/data-engineer/SKILL.md) for complete patterns. diff --git a/docs/agents/devops-infra.md b/docs/agents/devops-infra.md new file mode 100644 index 0000000..e4e0c37 --- /dev/null +++ b/docs/agents/devops-infra.md @@ -0,0 +1,95 @@ +# DevOps/Infrastructure Agent + +The DevOps/Infrastructure Agent guides all infrastructure and deployment decisions for ML/CV projects. + +**Agent directory:** `agents/devops-infra/` + +## Purpose + +This agent provides expert advice on packaging, deploying, and operating ML services in production. It covers Docker multi-stage builds, Kubernetes deployments with GPU support, CI/CD pipeline design, monitoring and observability, infrastructure as code, and security hardening. + +## Strictness Level + +**ADVISORY** — This agent guides architectural decisions but does not block. + +## When to Use + +- Containerizing an ML model for deployment +- Choosing between deployment targets (Cloud Run, ECS, Kubernetes) +- Designing CI/CD pipelines for ML projects +- Setting up monitoring and alerting for model serving +- Writing infrastructure as code (Terraform, Kubernetes manifests) +- Reviewing security posture of ML infrastructure + +## Decision Framework + +The agent uses a structured decision tree: + +``` +Need to package an ML application? +├── Single model serving → Dockerfile with multi-stage build +├── Multiple services → Docker Compose (local), K8s (prod) +├── GPU required? +│ ├── Training → NVIDIA base images +│ └── Inference → Optimized runtime images +└── No GPU → Python slim base image +``` + +## Key Patterns + +### Multi-Stage Docker Build + +```dockerfile +FROM python:3.11-slim AS builder +WORKDIR /app +RUN pip install --no-cache-dir uv +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +FROM python:3.11-slim AS runtime +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +COPY src/ ./src/ +RUN useradd --create-home appuser +USER appuser +CMD ["uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +### Kubernetes Deployment with GPU + +```yaml +spec: + containers: + - name: api + resources: + requests: + nvidia.com/gpu: "1" + limits: + nvidia.com/gpu: "1" + livenessProbe: + httpGet: + path: /health + readinessProbe: + httpGet: + path: /ready +``` + +## Security Checklist + +1. No secrets in code or images +2. Non-root container user +3. Minimal base images, scanned with Trivy +4. Resource limits on all containers +5. Image version pinning with content hashes + +## Related Skills + +- `docker-cv` — Detailed Docker patterns for CV workloads +- `github-actions` — CI/CD workflow definitions +- `gcp` — Google Cloud Platform deployment patterns +- `aws-sagemaker` — AWS managed ML infrastructure +- `fastapi` — The application framework behind deployed services + +## Full Reference + +See [`agents/devops-infra/SKILL.md`](https://github.com/ortizeg/whet/blob/main/agents/devops-infra/SKILL.md) for complete patterns including Docker Compose, Kubernetes HPA, Terraform, and monitoring. diff --git a/docs/agents/index.md b/docs/agents/index.md index 99ff26f..7cfbba4 100644 --- a/docs/agents/index.md +++ b/docs/agents/index.md @@ -1,6 +1,6 @@ # Agents Overview -The ai-cv-claude-skills framework includes four specialized agents that assist with different aspects of ML/CV development. Agents are divided into two categories based on their enforcement level. +The whet framework includes six specialized agents that assist with different aspects of ML/CV development. Agents are divided into two categories based on their enforcement level. ## Agent Summary @@ -8,12 +8,14 @@ The ai-cv-claude-skills framework includes four specialized agents that assist w |-------|------|------------|------------| | [Expert Coder](expert-coder.md) | Primary coding assistant | Advisory | Claude Code | | [ML Engineer](ml-engineer.md) | Architecture and training guidance | Advisory | Claude Code | +| [DevOps/Infra](devops-infra.md) | Infrastructure and deployment decisions | Advisory | Claude Code | +| [Data Engineer](data-engineer.md) | Data pipeline architecture and quality | Advisory | Claude Code | | [Code Review](code-review.md) | Automated quality enforcement | **Blocking** | GitHub Action | | [Test Engineer](test-engineer.md) | Testing and coverage enforcement | **Blocking** | GitHub Action | ## Advisory vs Blocking -**Advisory agents** guide development but do not prevent actions. They suggest improvements, explain rationale, and generate code following standards. The Expert Coder and ML Engineer agents operate in this mode through Claude Code. +**Advisory agents** guide development but do not prevent actions. They suggest improvements, explain rationale, and generate code following standards. The Expert Coder, ML Engineer, DevOps/Infra, and Data Engineer agents operate in this mode through Claude Code. **Blocking agents** run in CI and must pass before a pull request can be merged. The Code Review and Test Engineer agents operate as GitHub Actions that gate merges on code quality, type safety, test coverage, and security checks. @@ -21,7 +23,9 @@ The ai-cv-claude-skills framework includes four specialized agents that assist w 1. **During development**, the Expert Coder generates code following project patterns (abstractions, Pydantic, type hints). 2. **For ML decisions**, the ML Engineer reviews model architecture, training setup, and experiment configuration. -3. **On push/PR**, the Code Review agent validates formatting, linting, types, and security. -4. **On push/PR**, the Test Engineer runs the full test suite and enforces coverage thresholds. +3. **For infrastructure**, the DevOps/Infra agent guides Docker, Kubernetes, CI/CD, and cloud deployment decisions. +4. **For data pipelines**, the Data Engineer agent advises on ETL patterns, validation, versioning, and storage format selection. +5. **On push/PR**, the Code Review agent validates formatting, linting, types, and security. +6. **On push/PR**, the Test Engineer runs the full test suite and enforces coverage thresholds. This layered approach provides guidance during development and enforcement during integration. diff --git a/docs/agents/test-engineer.md b/docs/agents/test-engineer.md index 491f6b6..4fbd9fe 100644 --- a/docs/agents/test-engineer.md +++ b/docs/agents/test-engineer.md @@ -90,23 +90,23 @@ jobs: - uses: prefix-dev/setup-pixi@v0.8.1 with: pixi-version: latest - - run: pixi run pytest --cov=src --cov-fail-under=80 + - run: uv run pytest --cov=src --cov-fail-under=80 ``` ## Local Usage ```bash # Run all tests -pixi run test +uv run test # Run with coverage report -pixi run test-cov +uv run test-cov # Run specific test file -pixi run pytest tests/unit/test_models.py -v +uv run pytest tests/unit/test_models.py -v # Run tests matching pattern -pixi run pytest -k "test_model" +uv run pytest -k "test_model" ``` ## Additional Requirements diff --git a/docs/archetypes/cv-inference-service.md b/docs/archetypes/cv-inference-service.md index 88375d2..9201a36 100644 --- a/docs/archetypes/cv-inference-service.md +++ b/docs/archetypes/cv-inference-service.md @@ -47,7 +47,7 @@ This archetype packages trained models into production-ready REST API services. ```bash # Run locally -pixi run uvicorn my_project.serve:app --reload +uv run uvicorn my_project.serve:app --reload # Build and run with Docker docker compose up inference diff --git a/docs/archetypes/data-processing-pipeline.md b/docs/archetypes/data-processing-pipeline.md index 2e1b129..4cf0b5b 100644 --- a/docs/archetypes/data-processing-pipeline.md +++ b/docs/archetypes/data-processing-pipeline.md @@ -65,10 +65,10 @@ class Stage(ABC): ```bash # Run full pipeline -pixi run python -m my_project.pipeline +uv run python -m my_project.pipeline # Run single stage -pixi run python -m my_project.pipeline stage=preprocess +uv run python -m my_project.pipeline stage=preprocess # Reproduce with DVC dvc repro diff --git a/docs/archetypes/library-package.md b/docs/archetypes/library-package.md index 485222b..e2bebf8 100644 --- a/docs/archetypes/library-package.md +++ b/docs/archetypes/library-package.md @@ -48,13 +48,13 @@ This archetype creates a well-structured Python package that can be published to ```bash # Install in development mode -pixi run pip install -e ".[dev]" +uv run pip install -e ".[dev]" # Run tests -pixi run test +uv run test # Build package -pixi run python -m build +uv run python -m build # Publish to PyPI (via GitHub Actions on tag) git tag v0.1.0 && git push --tags diff --git a/docs/archetypes/model-zoo.md b/docs/archetypes/model-zoo.md index 22ed9da..386a995 100644 --- a/docs/archetypes/model-zoo.md +++ b/docs/archetypes/model-zoo.md @@ -70,13 +70,13 @@ Each model includes a `MODEL_CARD.md` documenting: ```bash # Run benchmarks -pixi run python benchmarks/run_benchmarks.py +uv run python benchmarks/run_benchmarks.py # Download all model weights -pixi run python -m my_project.download --all +uv run python -m my_project.download --all # Compare models -pixi run python -m my_project.benchmark --models resnet50,efficientnet_b0 +uv run python -m my_project.benchmark --models resnet50,efficientnet_b0 ``` ## Customization diff --git a/docs/archetypes/pytorch-training-project.md b/docs/archetypes/pytorch-training-project.md index 1584a06..d249348 100644 --- a/docs/archetypes/pytorch-training-project.md +++ b/docs/archetypes/pytorch-training-project.md @@ -52,13 +52,13 @@ This archetype provides a complete training pipeline structure for computer visi ```bash # Train with default config -pixi run python -m my_project.train +uv run python -m my_project.train # Override parameters -pixi run python -m my_project.train model=efficientnet trainer.max_epochs=50 +uv run python -m my_project.train model=efficientnet trainer.max_epochs=50 # Debug mode (1 batch, no logging) -pixi run python -m my_project.train trainer=debug +uv run python -m my_project.train trainer=debug ``` ## Customization diff --git a/docs/archetypes/research-notebook.md b/docs/archetypes/research-notebook.md index 96b75de..4570085 100644 --- a/docs/archetypes/research-notebook.md +++ b/docs/archetypes/research-notebook.md @@ -45,13 +45,13 @@ This archetype provides a structured notebook environment for ML research and ex ```bash # Start Jupyter -pixi run jupyter lab +uv run jupyter lab # Convert notebook to script -pixi run jupyter nbconvert --to script notebooks/01_data_exploration.ipynb +uv run jupyter nbconvert --to script notebooks/01_data_exploration.ipynb # Run all notebooks headless (for CI) -pixi run pytest --nbmake notebooks/ +uv run pytest --nbmake notebooks/ ``` ## Customization diff --git a/docs/examples/full-workflow.md b/docs/examples/full-workflow.md index 23d01ec..6ef8f7a 100644 --- a/docs/examples/full-workflow.md +++ b/docs/examples/full-workflow.md @@ -192,13 +192,13 @@ dropout: 0.1 ```bash # Default training -pixi run python -m yolo_detector.train +uv run python -m yolo_detector.train # Override config -pixi run python -m yolo_detector.train model.backbone=efficientnet_b0 trainer.max_epochs=50 +uv run python -m yolo_detector.train model.backbone=efficientnet_b0 trainer.max_epochs=50 # Debug mode -pixi run python -m yolo_detector.train trainer.fast_dev_run=true +uv run python -m yolo_detector.train trainer.fast_dev_run=true ``` ## Step 7: Export to ONNX diff --git a/docs/getting-started/first-project.md b/docs/getting-started/first-project.md index 01b0296..57e0f89 100644 --- a/docs/getting-started/first-project.md +++ b/docs/getting-started/first-project.md @@ -190,13 +190,13 @@ object-detector/ ```bash # Local training -pixi run python src/object_detector/train.py experiment=small +uv run python src/object_detector/train.py experiment=small # Docker training docker compose up train # Multi-GPU training -pixi run python src/object_detector/train.py trainer.devices=4 trainer.strategy=ddp +uv run python src/object_detector/train.py trainer.devices=4 trainer.strategy=ddp ``` ## Key Takeaways diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 1449175..c1fc874 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,122 +1,138 @@ # Installation -This guide walks you through setting up the AI/CV Claude Skills framework on your machine. +This guide walks you through setting up whet and the AI/CV skills framework. -## Prerequisites +## Quick Install -Before you begin, ensure you have the following installed: +The fastest way to use whet is via `uvx` (no install needed): -| Tool | Version | Purpose | -|------|---------|---------| -| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Latest | The CLI that loads and uses skills | -| [Pixi](https://pixi.sh/) | >= 0.30 | Package and environment management | -| [Git](https://git-scm.com/) | >= 2.30 | Version control | -| Python | >= 3.10 | Runtime (managed by Pixi) | +```bash +# List available skills +uvx whet list -### Installing Claude Code +# Install skills to Claude Code +uvx whet install --global +``` -Claude Code is Anthropic's official CLI for Claude. Install it via npm: +Or install permanently: ```bash -npm install -g @anthropic-ai/claude-code +uv tool install whet ``` -Verify the installation: +## Prerequisites -```bash -claude --version -``` +| Tool | Version | Purpose | +|------|---------|---------| +| [uv](https://docs.astral.sh/uv/) | Latest | Python package management | +| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | Latest | The CLI that loads and uses skills | +| [Git](https://git-scm.com/) | >= 2.30 | Version control | +| Python | >= 3.11 | Runtime (managed by uv) | -### Installing Pixi +### Installing uv -Pixi is the recommended package manager for this framework. It handles Python versions, CUDA dependencies, and reproducible environments. +uv is the recommended package manager for whet. It handles Python versions, dependencies, and tool installation. ```bash # macOS / Linux -curl -fsSL https://pixi.sh/install.sh | bash +curl -LsSf https://astral.sh/uv/install.sh | sh # Windows (PowerShell) -iwr -useb https://pixi.sh/install.ps1 | iex +powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" ``` Verify: ```bash -pixi --version +uv --version ``` -## Cloning the Repository +### Installing Claude Code + +Claude Code is Anthropic's official CLI for Claude. Install it via npm: ```bash -git clone https://github.com/ortizeg/ai-cv-claude-skills.git -cd ai-cv-claude-skills +npm install -g @anthropic-ai/claude-code ``` -## Repository Layout - -After cloning, you will see this structure: +Verify: -``` -ai-cv-claude-skills/ - skills/ # 21 skill modules (each with SKILL.md, README.md) - agents/ # 4 agent persona definitions - archetypes/ # 6 project archetype templates - docs/ # Documentation (you are reading it) - tests/ # Validation tests for skills - mkdocs.yml # Documentation site configuration +```bash +claude --version ``` -## Configuring Claude Code +## Using whet -The skills framework works by providing Claude Code with context files. There are two ways to use it: +### Install Skills Globally -### Option 1: Reference Skills Directly - -Point Claude Code at skill files when starting a session: +Install all skills to your AI coding agent: ```bash -# Load a specific skill -claude --skill ./skills/pytorch-lightning/SKILL.md +# Target Claude Code (default) +whet install --global + +# Target Google Antigravity +whet target antigravity +whet install --global -# Load multiple skills -claude --skill ./skills/pytorch-lightning/SKILL.md \ - --skill ./skills/hydra-config/SKILL.md \ - --skill ./skills/wandb/SKILL.md +# Target Cursor +whet target cursor +whet install --global ``` -### Option 2: Copy Skills Into Your Project +### Add Skills to a Project -For persistent use in a project, copy the relevant skill files into your project's `.claude/` directory: +Install specific skills into your current project: ```bash -# Create the Claude configuration directory in your project -mkdir -p /path/to/your/project/.claude/skills +whet add pytorch-lightning wandb hydra-config +``` -# Copy the skills you need -cp skills/pytorch-lightning/SKILL.md /path/to/your/project/.claude/skills/pytorch-lightning.md -cp skills/hydra-config/SKILL.md /path/to/your/project/.claude/skills/hydra-config.md +### Browse Skills + +```bash +# List all skills +whet list + +# Search by keyword +whet search training + +# Show skill details +whet info pytorch-lightning ``` -### Option 3: Symlink the Skills Directory +## Development Setup -For development workflows where you want the latest skill updates: +If you plan to contribute to whet or run the tests: ```bash -ln -s /path/to/ai-cv-claude-skills/skills /path/to/your/project/.claude/skills +git clone https://github.com/ortizeg/whet.git +cd whet +uv sync --all-extras ``` -## Setting Up a Development Environment - -If you plan to contribute to the framework itself or run the tests: +### Running Checks ```bash -cd ai-cv-claude-skills +# Run all tests +just test + +# Lint +just lint + +# Type check +just typecheck -# Create environment with Pixi -pixi install +# Run everything +just check +``` + +Or directly with uv: -# Run the test suite -pixi run pytest tests/ +```bash +uv run pytest tests/ -v +uv run ruff check . +uv run mypy src/whet/ tests/ --strict ``` ## Verifying the Installation @@ -124,17 +140,26 @@ pixi run pytest tests/ Run the completeness test to confirm all skills are properly structured: ```bash -pixi run pytest tests/test_skills_completeness.py -v +uv run pytest tests/test_skills_completeness.py -v ``` This test verifies that: -- All 21 expected skills exist -- Each skill has both a `SKILL.md` and `README.md` +- All skills have YAML frontmatter in SKILL.md +- Each skill has `SKILL.md`, `README.md`, and `skill.toml` - `SKILL.md` files contain substantial content (500+ characters) - `SKILL.md` files include code examples and headers - `README.md` files explain the skill's purpose +## Supported Platforms + +| Platform | Skill Format | Global Directory | +|----------|-------------|-----------------| +| **Claude Code** | SKILL.md (native) | `~/.claude/skills/` | +| **Google Antigravity** | SKILL.md (native) | `~/.gemini/antigravity/skills/` | +| **Cursor** | .md rules (converted) | `~/.cursor/rules/` | +| **GitHub Copilot** | Single aggregated file | `~/.github/` | + ## Next Steps -With the framework installed, proceed to the [Quick Start](quick-start.md) guide to create your first AI-assisted CV project. +With whet installed, proceed to the [Quick Start](quick-start.md) guide to create your first AI-assisted CV project. diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 60f1c18..d57a2af 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -10,21 +10,23 @@ Browse the [Skills Overview](../skills/index.md) to find skills relevant to your - **Hydra Config** -- Configuration management - **Code Quality** -- Linting, formatting, type checking -## Step 2: Start Claude Code with Skills +## Step 2: Install Skills -Open your terminal in the project directory where you want to generate code: +Install the skills you need into your project: ```bash mkdir my-classifier && cd my-classifier + +# Install skills with whet +whet add pytorch-lightning hydra-config code-quality ``` -Start Claude Code and reference the skills: +Start Claude Code — the skills are automatically discovered: ```bash -claude "You are an ML engineer. Use the pytorch-lightning, hydra-config, and - code-quality skills from ai-cv-claude-skills to create a ResNet image - classifier training pipeline. Include data loading, augmentations, - training configuration, and evaluation." +claude "Create a ResNet image classifier training pipeline. + Include data loading, augmentations, training configuration, + and evaluation." ``` ## Step 3: Review the Generated Structure @@ -52,7 +54,7 @@ my-classifier/ test_model.py test_data.py pyproject.toml - pixi.toml + pyproject.toml ``` ## Step 4: Configure and Train @@ -72,7 +74,7 @@ data: Run training: ```bash -pixi run python src/my_classifier/train.py data=custom trainer.max_epochs=50 +uv run python src/my_classifier/train.py data=custom trainer.max_epochs=50 ``` ## Step 5: Add More Skills @@ -81,18 +83,20 @@ As your project grows, layer on additional skills: ```bash # Add experiment tracking -claude "Add Weights & Biases integration to this project using the wandb skill. - Log metrics, hyperparameters, and model checkpoints." +whet add wandb # Add Docker support -claude "Containerize this project for GPU training using the docker-cv skill. - Include multi-stage build and CUDA support." +whet add docker-cv + +# Add model serving +whet add fastapi # Add CI/CD -claude "Set up GitHub Actions for this project using the github-actions skill. - Include linting, testing, and model training validation." +whet add github-actions ``` +Each skill is automatically discovered by Claude Code and used to guide code generation. + ## Common Skill Combinations Here are proven combinations for common project types: @@ -104,7 +108,7 @@ pytorch-lightning + hydra-config + wandb + code-quality + testing ### Inference Service ``` -docker-cv + onnx + pydantic-strict + code-quality + testing +fastapi + onnx + docker-cv + pydantic-strict + code-quality + testing ``` ### Research Project diff --git a/docs/guides/adding-skills.md b/docs/guides/adding-skills.md index 27a3a23..9b24a96 100644 --- a/docs/guides/adding-skills.md +++ b/docs/guides/adding-skills.md @@ -1,6 +1,6 @@ # Adding Skills -This guide explains how to contribute new skills to the ai-cv-claude-skills framework. +This guide explains how to contribute new skills to the whet framework. ## Skill Anatomy @@ -127,10 +127,10 @@ nav: ```bash # Run tests to confirm skill is complete -pixi run pytest tests/test_skills_completeness.py -v +uv run pytest tests/test_skills_completeness.py -v # Check documentation builds -pixi run docs-build +uv run docs-build ``` ## Checklist Before Submitting PR @@ -141,8 +141,8 @@ pixi run docs-build - [ ] Skill added to expected set in `test_skills_completeness.py` - [ ] Documentation page created in `docs/skills/` - [ ] `mkdocs.yml` nav updated -- [ ] All tests pass: `pixi run test` -- [ ] Linting passes: `pixi run lint` +- [ ] All tests pass: `uv run test` +- [ ] Linting passes: `uv run lint` ## Example: Adding an "Albumentations" Skill diff --git a/docs/guides/best-practices.md b/docs/guides/best-practices.md index 6bc8906..b4c8aac 100644 --- a/docs/guides/best-practices.md +++ b/docs/guides/best-practices.md @@ -1,6 +1,6 @@ # Best Practices -Comprehensive best practices for AI/CV projects using the ai-cv-claude-skills framework. +Comprehensive best practices for AI/CV projects using the whet framework. ## Project Organization diff --git a/docs/guides/creating-projects.md b/docs/guides/creating-projects.md index ac7079b..11c7ae9 100644 --- a/docs/guides/creating-projects.md +++ b/docs/guides/creating-projects.md @@ -1,6 +1,6 @@ # Creating Projects -This guide walks through creating a new AI/CV project using the ai-cv-claude-skills framework. +This guide walks through creating a new AI/CV project using the whet framework. ## Prerequisites @@ -17,7 +17,7 @@ claude ## Step 2: Tell Claude What You Want ``` -You: "Using ai-cv-claude-skills, create a new pytorch-training-project +You: "Using whet, create a new pytorch-training-project called 'face-detection' for detecting faces in video streams" ``` @@ -105,11 +105,11 @@ git commit -m "Initial project from pytorch-training-project archetype" pixi install # Install pre-commit hooks -pixi run pre-commit install +uv run pre-commit install # Verify everything works -pixi run lint -pixi run test +uv run lint +uv run test ``` ## Full Example diff --git a/docs/guides/customization.md b/docs/guides/customization.md index 507b765..c557896 100644 --- a/docs/guides/customization.md +++ b/docs/guides/customization.md @@ -1,6 +1,6 @@ # Customization -This guide covers how to adapt the ai-cv-claude-skills framework to your specific needs. +This guide covers how to adapt the whet framework to your specific needs. ## Overriding Default Configurations diff --git a/docs/index.md b/docs/index.md index c20cca1..ec33219 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,14 +6,14 @@ ## What Is This? -AI/CV Claude Skills is a curated collection of **25 specialized skills**, **4 agent personas**, and **6 project archetypes** that teach Claude Code how to write production-grade computer vision and deep learning code. Instead of getting generic AI-generated code, you get output that follows the exact patterns, libraries, and conventions used by experienced CV/ML engineers. +AI/CV Claude Skills is a curated collection of **30 specialized skills**, **6 agent personas**, and **6 project archetypes** that teach Claude Code how to write production-grade computer vision and deep learning code. Instead of getting generic AI-generated code, you get output that follows the exact patterns, libraries, and conventions used by experienced CV/ML engineers. Each skill is a focused knowledge module that Claude Code loads on demand. When you tell Claude to "use the PyTorch Lightning skill," it gains deep understanding of Lightning best practices, project structure, and common patterns -- producing code that looks like it was written by a specialist. ## Key Features -- **25 Domain-Specific Skills** -- From PyTorch Lightning training loops to ONNX model export, each skill encodes expert knowledge about a specific tool or pattern. -- **4 Agent Personas** -- Pre-configured behavioral profiles (Expert Coder, ML Engineer, Code Reviewer, Test Engineer) that adjust Claude's strictness, focus, and output style. +- **30 Domain-Specific Skills** -- From PyTorch Lightning training loops to ONNX model export, each skill encodes expert knowledge about a specific tool or pattern. +- **6 Agent Personas** -- Pre-configured behavioral profiles (Expert Coder, ML Engineer, DevOps/Infra, Data Engineer, Code Reviewer, Test Engineer) that adjust Claude's strictness, focus, and output style. - **6 Project Archetypes** -- Complete project templates for common CV/ML project types: training pipelines, inference services, research notebooks, library packages, data pipelines, and model zoos. - **Composable by Design** -- Skills combine naturally. A PyTorch training project might use the PyTorch Lightning, Hydra Config, Weights & Biases, and Docker CV skills together. - **Production Standards** -- Every skill enforces type hints, comprehensive error handling, proper logging, and test coverage. No prototype-quality code. @@ -44,13 +44,13 @@ Claude Code will generate: ## Project Structure ``` -ai-cv-claude-skills/ +whet/ skills/ # 25 domain-specific skill modules master-skill/ # Core conventions for all CV/ML code pytorch-lightning/ # PyTorch Lightning training patterns pydantic-strict/ # Strict data validation with Pydantic ... - agents/ # 4 agent persona definitions + agents/ # 6 agent persona definitions expert-coder/ # High-strictness coding agent ml-engineer/ # ML-focused development agent ... @@ -76,8 +76,8 @@ Ready to dive in? Head to the [Installation](getting-started/installation.md) gu | Section | Description | |---------|-------------| | [Getting Started](getting-started/installation.md) | Install prerequisites, clone the repo, and configure Claude Code | -| [Skills Overview](skills/index.md) | Browse all 25 skills with categories and descriptions | -| [Agents](agents/index.md) | Learn about the 4 agent personas and when to use each | +| [Skills Overview](skills/index.md) | Browse all 30 skills with categories and descriptions | +| [Agents](agents/index.md) | Learn about the 6 agent personas and when to use each | | [Archetypes](archetypes/index.md) | Explore the 6 project templates | | [Guides](guides/creating-projects.md) | Step-by-step guides for common workflows | | [Examples](examples/full-workflow.md) | End-to-end examples with real code | diff --git a/docs/skills/abstraction-patterns.md b/docs/skills/abstraction-patterns.md index 4ca27ae..f604f94 100644 --- a/docs/skills/abstraction-patterns.md +++ b/docs/skills/abstraction-patterns.md @@ -125,4 +125,4 @@ class FocalLoss(DetectionLoss): ## Full Reference -See [`skills/abstraction-patterns/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/abstraction-patterns/SKILL.md) for patterns including factory methods, builder patterns for complex pipelines, and mixin classes for common ML functionality. +See [`skills/abstraction-patterns/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/abstraction-patterns/SKILL.md) for patterns including factory methods, builder patterns for complex pipelines, and mixin classes for common ML functionality. diff --git a/docs/skills/aws-sagemaker.md b/docs/skills/aws-sagemaker.md new file mode 100644 index 0000000..33dfd69 --- /dev/null +++ b/docs/skills/aws-sagemaker.md @@ -0,0 +1,98 @@ +# AWS SageMaker + +The AWS SageMaker skill provides expert patterns for ML training and deployment on Amazon SageMaker, covering training jobs, endpoints, pipelines, and hyperparameter tuning. + +**Skill directory:** `skills/aws-sagemaker/` + +## Purpose + +SageMaker is the standard managed ML platform on AWS. This skill encodes best practices for structuring SageMaker projects: PyTorch estimator configuration with distributed training, custom inference handlers, SageMaker Pipelines with conditional model registration, hyperparameter tuning jobs, and local mode testing. + +## When to Use + +Use this skill whenever you need to: + +- Train models on AWS GPU instances (ml.g5, ml.p4, ml.trn1) +- Deploy models as real-time SageMaker endpoints with auto-scaling +- Build automated ML pipelines (preprocessing, training, evaluation, registration) +- Run hyperparameter tuning to optimize model performance +- Process large datasets with SageMaker Processing jobs + +## Key Patterns + +### Training Job with PyTorch Estimator + +```python +from sagemaker.pytorch import PyTorch + +estimator = PyTorch( + entry_point="train.py", + source_dir="src/training", + role=config.role, + instance_type="ml.g5.2xlarge", + instance_count=1, + framework_version="2.1.0", + py_version="py310", + output_path=config.output_s3_uri, + hyperparameters=hyperparameters.model_dump(), +) + +estimator.fit( + inputs={"train": train_s3_uri, "validation": val_s3_uri}, + wait=False, +) +``` + +### Custom Inference Handler + +```python +def model_fn(model_dir: str) -> torch.nn.Module: + """Load trained model.""" + model = build_model("resnet50", num_classes=10) + model.load_state_dict(torch.load(f"{model_dir}/model.pth")) + model.eval() + return model.cuda() + + +def input_fn(request_body: bytes, content_type: str) -> torch.Tensor: + """Deserialize input to tensor.""" + ... + + +def predict_fn(input_data: torch.Tensor, model: torch.nn.Module) -> dict: + """Run inference.""" + ... +``` + +### SageMaker Pipeline + +```python +from sagemaker.workflow.pipeline import Pipeline +from sagemaker.workflow.steps import ProcessingStep, TrainingStep +from sagemaker.workflow.condition_step import ConditionStep + +pipeline = Pipeline( + name="cv-training-pipeline", + parameters=[input_data, accuracy_threshold], + steps=[preprocess_step, training_step, condition_step], +) +``` + +## Anti-Patterns to Avoid + +- Do not hardcode S3 paths -- use SageMaker session defaults and pipeline parameters +- Do not skip local mode testing -- validate with `instance_type="local"` first +- Do not put credentials in training scripts -- SageMaker injects IAM roles +- Do not download datasets inside training scripts -- use SageMaker input channels + +## Combines Well With + +- **PyTorch Lightning** -- LightningModule inside SageMaker training jobs +- **Docker CV** -- Custom training containers when default images are insufficient +- **W&B / MLflow** -- Experiment tracking inside SageMaker training containers +- **DVC** -- Data versioning with S3 remote storage +- **GCP** -- Alternative cloud platform patterns for comparison + +## Full Reference + +See [`skills/aws-sagemaker/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/aws-sagemaker/SKILL.md) for complete patterns including batch transform, hyperparameter tuning, and S3 data management. diff --git a/docs/skills/code-quality.md b/docs/skills/code-quality.md index d933d4a..b0e2a2b 100644 --- a/docs/skills/code-quality.md +++ b/docs/skills/code-quality.md @@ -68,13 +68,13 @@ ignore_missing_imports = true ```bash # Format code -pixi run ruff format src/ tests/ +uv run ruff format src/ tests/ # Lint with auto-fix -pixi run ruff check --fix src/ tests/ +uv run ruff check --fix src/ tests/ # Type check -pixi run mypy src/ +uv run mypy src/ ``` ## Anti-Patterns to Avoid @@ -93,4 +93,4 @@ pixi run mypy src/ ## Full Reference -See [`skills/code-quality/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/code-quality/SKILL.md) for the full configuration including per-file rule overrides, custom ruff rules for ML patterns, and mypy plugin configuration for PyTorch and Pydantic. +See [`skills/code-quality/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/code-quality/SKILL.md) for the full configuration including per-file rule overrides, custom ruff rules for ML patterns, and mypy plugin configuration for PyTorch and Pydantic. diff --git a/docs/skills/docker-cv.md b/docs/skills/docker-cv.md index b5c459b..6dd94b6 100644 --- a/docs/skills/docker-cv.md +++ b/docs/skills/docker-cv.md @@ -90,4 +90,4 @@ services: ## Full Reference -See [`skills/docker-cv/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/docker-cv/SKILL.md) for patterns including inference-optimized images, TensorRT integration, and Kubernetes deployment manifests. +See [`skills/docker-cv/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/docker-cv/SKILL.md) for patterns including inference-optimized images, TensorRT integration, and Kubernetes deployment manifests. diff --git a/docs/skills/dvc.md b/docs/skills/dvc.md index 9004319..02fcabc 100644 --- a/docs/skills/dvc.md +++ b/docs/skills/dvc.md @@ -99,4 +99,4 @@ dvc pull ## Full Reference -See [`skills/dvc/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/dvc/SKILL.md) for patterns including DVC experiments, metrics comparison across branches, and integration with cloud storage providers. +See [`skills/dvc/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/dvc/SKILL.md) for patterns including DVC experiments, metrics comparison across branches, and integration with cloud storage providers. diff --git a/docs/skills/fastapi.md b/docs/skills/fastapi.md new file mode 100644 index 0000000..16061b7 --- /dev/null +++ b/docs/skills/fastapi.md @@ -0,0 +1,108 @@ +# FastAPI + +The FastAPI skill provides expert patterns for building ML model serving APIs with FastAPI, covering async endpoints, Pydantic request/response validation, dependency injection, middleware, and production deployment. + +**Skill directory:** `skills/fastapi/` + +## Purpose + +FastAPI is the standard framework for serving ML models over HTTP. This skill encodes the best practices for structuring FastAPI applications in ML/CV contexts: application factory pattern with lifespan-managed model loading, typed request/response schemas with Pydantic validation, dependency injection for model and preprocessor instances, and production deployment with Uvicorn. + +## When to Use + +Use this skill whenever you need to: + +- Serve trained models (ONNX, TensorRT, PyTorch) behind REST endpoints +- Build real-time WebSocket streaming for video inference pipelines +- Construct microservices wrapping ML inference +- Create APIs with health checks, batch endpoints, and structured error responses + +## Key Patterns + +### Application Factory with Lifespan + +```python +from __future__ import annotations + +from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator + +from fastapi import FastAPI + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + """Load models on startup, release on shutdown.""" + await model_registry.load(config) + yield + await model_registry.shutdown() + + +def create_app(config: AppConfig | None = None) -> FastAPI: + config = config or AppConfig() + app = FastAPI(title=config.title, lifespan=lifespan) + app.include_router(prediction.router, prefix="/api/v1") + return app +``` + +### Pydantic Request/Response Schemas + +```python +from pydantic import BaseModel, Field, field_validator + + +class PredictionRequest(BaseModel, frozen=True): + image_b64: str = Field(..., description="Base64-encoded image") + confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0) + + @field_validator("image_b64") + @classmethod + def validate_base64(cls, v: str) -> str: + import base64 + base64.b64decode(v, validate=True) + return v + + +class PredictionResponse(BaseModel, frozen=True): + detections: list[Detection] + inference_time_ms: float + model_version: str +``` + +### Dependency Injection for Models + +```python +from typing import Annotated +from fastapi import Depends + + +@router.post("/predict", response_model=PredictionResponse) +async def predict( + request: PredictionRequest, + model: Annotated[Model, Depends(get_model)], + preprocessor: Annotated[Preprocessor, Depends(get_preprocessor)], +) -> PredictionResponse: + image = preprocessor.decode_and_preprocess(request.image_b64) + detections = await model.predict(image) + return PredictionResponse(detections=detections, ...) +``` + +## Anti-Patterns to Avoid + +- Do not use `dict` for request/response schemas -- define explicit Pydantic models +- Do not load models inside endpoint functions -- use lifespan handlers +- Do not block the event loop with synchronous inference -- use async or `run_in_executor` +- Do not hardcode CORS origins in production -- load from configuration +- Do not return raw numpy arrays -- serialize to lists or base64 + +## Combines Well With + +- **Pydantic Strict** -- Frozen BaseModel patterns for all API schemas +- **ONNX / TensorRT** -- Optimized model formats loaded in lifespan +- **Docker CV** -- Production containerization for FastAPI services +- **Loguru** -- Structured logging in middleware and exception handlers +- **Testing** -- Async endpoint testing with httpx + +## Full Reference + +See [`skills/fastapi/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/fastapi/SKILL.md) for complete patterns including WebSocket streaming, background tasks, and middleware. diff --git a/docs/skills/gcp.md b/docs/skills/gcp.md index f212cf2..3ce2360 100644 --- a/docs/skills/gcp.md +++ b/docs/skills/gcp.md @@ -85,4 +85,4 @@ class VertexJobConfig(BaseModel, frozen=True): ## Full Reference -See [`skills/gcp/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/gcp/SKILL.md) for Artifact Registry setup, Cloud Storage patterns, Vertex AI GPU reference, authentication flows, Pydantic config models, and pixi task definitions. +See [`skills/gcp/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/gcp/SKILL.md) for Artifact Registry setup, Cloud Storage patterns, Vertex AI GPU reference, authentication flows, Pydantic config models, and pixi task definitions. diff --git a/docs/skills/github-actions.md b/docs/skills/github-actions.md index 2ffe841..757ae23 100644 --- a/docs/skills/github-actions.md +++ b/docs/skills/github-actions.md @@ -37,22 +37,22 @@ jobs: - uses: prefix-dev/setup-pixi@v0.8.1 with: pixi-version: v0.30.0 - - run: pixi run ruff check src/ tests/ - - run: pixi run ruff format --check src/ tests/ + - run: uv run ruff check src/ tests/ + - run: uv run ruff format --check src/ tests/ typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: prefix-dev/setup-pixi@v0.8.1 - - run: pixi run mypy src/ + - run: uv run mypy src/ test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: prefix-dev/setup-pixi@v0.8.1 - - run: pixi run pytest tests/ -v --tb=short + - run: uv run pytest tests/ -v --tb=short ``` ### Docker Build and Push @@ -123,4 +123,4 @@ release: ## Full Reference -See [`skills/github-actions/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/github-actions/SKILL.md) for patterns including GPU-enabled runners, model artifact caching, and matrix builds across Python versions. +See [`skills/github-actions/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/github-actions/SKILL.md) for patterns including GPU-enabled runners, model artifact caching, and matrix builds across Python versions. diff --git a/docs/skills/github-repo-setup.md b/docs/skills/github-repo-setup.md index d9cfd6c..8b936f1 100644 --- a/docs/skills/github-repo-setup.md +++ b/docs/skills/github-repo-setup.md @@ -81,4 +81,4 @@ class RepoConfig(BaseModel): ## Full Reference -See [`skills/github-repo-setup/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/github-repo-setup/SKILL.md) for the complete setup script, Pydantic models, issue templates, CODEOWNERS patterns, and merge strategy guidance. +See [`skills/github-repo-setup/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/github-repo-setup/SKILL.md) for the complete setup script, Pydantic models, issue templates, CODEOWNERS patterns, and merge strategy guidance. diff --git a/docs/skills/gradio.md b/docs/skills/gradio.md new file mode 100644 index 0000000..d39320b --- /dev/null +++ b/docs/skills/gradio.md @@ -0,0 +1,67 @@ +# Gradio + +The Gradio skill provides expert patterns for building ML model demos and interactive interfaces with Gradio, covering Interface/Blocks layouts, input/output components, and deployment to Hugging Face Spaces. + +**Skill directory:** `skills/gradio/` + +## Purpose + +Gradio is the standard tool for quickly building interactive demos for ML models. This skill encodes best practices for structuring Gradio applications: choosing between `gr.Interface` and `gr.Blocks`, handling image/video/text inputs, integrating with PyTorch and ONNX models, and deploying to Hugging Face Spaces. + +## When to Use + +Use this skill whenever you need to: + +- Build interactive demos for trained models +- Create quick prototypes for stakeholder review +- Deploy model demos to Hugging Face Spaces +- Build custom UIs with multi-step workflows using Blocks + +## Key Patterns + +### Quick Interface + +```python +import gradio as gr + +def classify(image): + predictions = model(preprocess(image)) + return {labels[i]: float(p) for i, p in enumerate(predictions)} + +demo = gr.Interface( + fn=classify, + inputs=gr.Image(type="pil"), + outputs=gr.Label(num_top_classes=5), + title="Image Classifier", +) +demo.launch() +``` + +### Blocks Layout + +```python +with gr.Blocks() as demo: + gr.Markdown("# Object Detection") + with gr.Row(): + input_image = gr.Image(type="pil") + output_image = gr.Image() + confidence = gr.Slider(0, 1, value=0.5, label="Confidence") + btn = gr.Button("Detect") + btn.click(detect, [input_image, confidence], output_image) +``` + +## Anti-Patterns to Avoid + +- Do not load models inside prediction functions -- load once at module level +- Do not use `share=True` for production deployments -- use proper hosting +- Do not skip input validation -- validate image formats and sizes + +## Combines Well With + +- **Hugging Face** -- Load models from Hub, deploy to Spaces +- **FastAPI** -- Embed Gradio as a sub-app in larger services +- **ONNX / TensorRT** -- Serve optimized models through Gradio interfaces + +## Full Reference + +See [`skills/gradio/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/gradio/SKILL.md) for complete patterns. diff --git a/docs/skills/huggingface.md b/docs/skills/huggingface.md new file mode 100644 index 0000000..77b58fd --- /dev/null +++ b/docs/skills/huggingface.md @@ -0,0 +1,101 @@ +# Hugging Face + +The Hugging Face skill provides expert patterns for the Hugging Face ecosystem — Transformers, Datasets, PEFT, and the Model Hub — covering pretrained model loading, fine-tuning, and inference. + +**Skill directory:** `skills/huggingface/` + +## Purpose + +The Hugging Face ecosystem is the standard toolkit for working with pretrained models. This skill encodes best practices for loading models with AutoModel classes and version pinning, preprocessing datasets with batched mapping, fine-tuning with the Trainer API, parameter-efficient fine-tuning with LoRA, and publishing models with model cards. + +## When to Use + +Use this skill whenever you need to: + +- Load pretrained vision models (ResNet, ViT, DETR, CLIP) or text models (BERT, RoBERTa) +- Fine-tune models on custom datasets with the Trainer API +- Use PEFT/LoRA for parameter-efficient adaptation with limited data +- Build quick inference pipelines for classification, detection, or zero-shot tasks +- Publish models and datasets to the Hugging Face Hub + +## Key Patterns + +### AutoModel Loading + +```python +from transformers import AutoModelForImageClassification, AutoImageProcessor + +processor = AutoImageProcessor.from_pretrained( + "microsoft/resnet-50", + revision="main", +) + +model = AutoModelForImageClassification.from_pretrained( + "microsoft/resnet-50", + revision="main", + torch_dtype=torch.float32, +) +``` + +### Fine-Tuning with Trainer + +```python +from transformers import Trainer, TrainingArguments, EarlyStoppingCallback + +training_args = TrainingArguments( + output_dir="outputs/finetuned", + num_train_epochs=10, + per_device_train_batch_size=32, + learning_rate=5e-5, + fp16=True, + eval_strategy="epoch", + load_best_model_at_end=True, +) + +trainer = Trainer( + model=model, + args=training_args, + train_dataset=train_dataset, + eval_dataset=eval_dataset, + compute_metrics=compute_metrics, + callbacks=[EarlyStoppingCallback(early_stopping_patience=3)], +) + +trainer.train() +``` + +### PEFT / LoRA + +```python +from peft import LoraConfig, TaskType, get_peft_model + +lora_config = LoraConfig( + task_type=TaskType.SEQ_CLS, + r=16, + lora_alpha=32, + lora_dropout=0.1, + target_modules=["query", "value"], +) + +model = get_peft_model(base_model, lora_config) +# Trains <1% of parameters +``` + +## Anti-Patterns to Avoid + +- Do not hardcode model class names -- use `AutoModel`, `AutoTokenizer` for flexibility +- Do not download models inside training loops -- load once, cache with `cache_dir` +- Do not skip `revision` parameter -- pin model versions for reproducibility +- Do not fine-tune all parameters with limited data -- use PEFT/LoRA + +## Combines Well With + +- **PyTorch Lightning** -- Custom training loops wrapping HF models +- **ONNX** -- Export HF models for optimized inference +- **W&B** -- Experiment tracking via `report_to=["wandb"]` +- **AWS SageMaker** -- Deploy HF models with SageMaker HF DLC +- **FastAPI** -- Serve HF pipelines behind async endpoints + +## Full Reference + +See [`skills/huggingface/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/huggingface/SKILL.md) for complete patterns including datasets, pipelines, model hub publishing, and quantization. diff --git a/docs/skills/hydra-config.md b/docs/skills/hydra-config.md index 2c1e135..47e3956 100644 --- a/docs/skills/hydra-config.md +++ b/docs/skills/hydra-config.md @@ -111,4 +111,4 @@ if __name__ == "__main__": ## Full Reference -See [`skills/hydra-config/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/hydra-config/SKILL.md) for patterns including multi-run sweeps, Hydra callbacks, and integration with Optuna for hyperparameter optimization. +See [`skills/hydra-config/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/hydra-config/SKILL.md) for patterns including multi-run sweeps, Hydra callbacks, and integration with Optuna for hyperparameter optimization. diff --git a/docs/skills/index.md b/docs/skills/index.md index 352db63..c177e2e 100644 --- a/docs/skills/index.md +++ b/docs/skills/index.md @@ -49,6 +49,16 @@ Skills specific to computer vision workflows. | [Matplotlib](matplotlib.md) | Visualization and plotting for CV results | matplotlib | | [ONNX](onnx.md) | Model export and optimization | onnx, onnxruntime, onnxslim | | [TensorRT](tensorrt.md) | GPU-optimized inference engine building | TensorRT, trtexec | +| [Hugging Face](huggingface.md) | Pretrained models, fine-tuning, PEFT/LoRA | transformers, datasets, peft | + +### Cloud & Deployment + +| Skill | Description | Key Libraries | +|-------|-------------|---------------| +| [AWS SageMaker](aws-sagemaker.md) | ML training and deployment on AWS | sagemaker, boto3 | +| [FastAPI](fastapi.md) | ML model serving APIs | FastAPI, uvicorn | +| [Kubernetes](kubernetes.md) | ML service deployment and orchestration on K8s | kubectl, helm | +| [Gradio](gradio.md) | Interactive ML model demos and prototypes | Gradio | ### Infrastructure & DevOps @@ -117,5 +127,5 @@ Each skill is validated by automated tests that check: Run the validation suite: ```bash -pixi run pytest tests/test_skills_completeness.py -v +uv run pytest tests/test_skills_completeness.py -v ``` diff --git a/docs/skills/kubernetes.md b/docs/skills/kubernetes.md new file mode 100644 index 0000000..5ee5836 --- /dev/null +++ b/docs/skills/kubernetes.md @@ -0,0 +1,76 @@ +# Kubernetes + +The Kubernetes skill provides expert patterns for deploying ML services on Kubernetes, covering Deployments, Services, GPU scheduling, Helm charts, and autoscaling. + +**Skill directory:** `skills/kubernetes/` + +## Purpose + +Kubernetes is the standard orchestration platform for production ML serving at scale. This skill encodes best practices for deploying ML models on K8s: GPU resource requests, health check probes, Horizontal Pod Autoscaler configuration, Helm chart structure, and environment management with Kustomize. + +## When to Use + +Use this skill whenever you need to: + +- Deploy model serving APIs on Kubernetes with GPU support +- Set up autoscaling based on inference latency or GPU utilization +- Manage multi-environment deployments (dev/staging/prod) +- Package ML services as Helm charts + +## Key Patterns + +### GPU Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +spec: + template: + spec: + containers: + - name: model-api + resources: + requests: + nvidia.com/gpu: "1" + limits: + nvidia.com/gpu: "1" + livenessProbe: + httpGet: + path: /health + readinessProbe: + httpGet: + path: /ready +``` + +### Horizontal Pod Autoscaler + +```yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +spec: + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 +``` + +## Anti-Patterns to Avoid + +- Do not use `latest` image tags -- pin versions with SHA digests +- Do not skip resource limits -- unbounded pods risk node instability +- Do not store model weights in container images -- use persistent volumes + +## Combines Well With + +- **Docker CV** -- Container images deployed to K8s +- **FastAPI** -- The serving framework inside K8s pods +- **GCP / AWS SageMaker** -- Managed K8s clusters (GKE, EKS) + +## Full Reference + +See [`skills/kubernetes/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/kubernetes/SKILL.md) for complete patterns including Helm charts, Kustomize, and monitoring. diff --git a/docs/skills/library-review.md b/docs/skills/library-review.md index 6a8302f..9feebf6 100644 --- a/docs/skills/library-review.md +++ b/docs/skills/library-review.md @@ -85,4 +85,4 @@ The skill emphasizes criteria specific to ML work: ## Full Reference -See [`skills/library-review/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/library-review/SKILL.md) for the complete evaluation rubric, including security assessment, license compatibility matrix, and migration cost estimation. +See [`skills/library-review/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/library-review/SKILL.md) for the complete evaluation rubric, including security assessment, license compatibility matrix, and migration cost estimation. diff --git a/docs/skills/loguru.md b/docs/skills/loguru.md index 387edcb..a0a68a8 100644 --- a/docs/skills/loguru.md +++ b/docs/skills/loguru.md @@ -75,4 +75,4 @@ logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) ## Full Reference -See [`skills/loguru/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/loguru/SKILL.md) for complete patterns including custom ML log levels, JSON serialization, Pydantic configuration, and exception handling decorators. +See [`skills/loguru/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/loguru/SKILL.md) for complete patterns including custom ML log levels, JSON serialization, Pydantic configuration, and exception handling decorators. diff --git a/docs/skills/master-skill.md b/docs/skills/master-skill.md index 84d9f73..618f329 100644 --- a/docs/skills/master-skill.md +++ b/docs/skills/master-skill.md @@ -86,4 +86,4 @@ The Master Skill's conventions are assumed by every other skill. When the PyTorc ## Full Reference -See the complete skill definition in [`skills/master-skill/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/master-skill/SKILL.md) for the full set of conventions, including docstring format, project layout rules, and anti-patterns to avoid. +See the complete skill definition in [`skills/master-skill/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/master-skill/SKILL.md) for the full set of conventions, including docstring format, project layout rules, and anti-patterns to avoid. diff --git a/docs/skills/matplotlib.md b/docs/skills/matplotlib.md index 20e3fc5..a34a505 100644 --- a/docs/skills/matplotlib.md +++ b/docs/skills/matplotlib.md @@ -106,4 +106,4 @@ def plot_image_grid( ## Full Reference -See [`skills/matplotlib/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/matplotlib/SKILL.md) for patterns including confusion matrix heatmaps, ROC curves, t-SNE embeddings, and animated training progress. +See [`skills/matplotlib/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/matplotlib/SKILL.md) for patterns including confusion matrix heatmaps, ROC curves, t-SNE embeddings, and animated training progress. diff --git a/docs/skills/mlflow.md b/docs/skills/mlflow.md index 54ce173..a596419 100644 --- a/docs/skills/mlflow.md +++ b/docs/skills/mlflow.md @@ -93,4 +93,4 @@ with mlflow.start_run(run_name="resnet50-baseline") as run: ## Full Reference -See [`skills/mlflow/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/mlflow/SKILL.md) for patterns including model serving, A/B testing with model stages, and custom MLflow plugins for CV-specific artifact types. +See [`skills/mlflow/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/mlflow/SKILL.md) for patterns including model serving, A/B testing with model stages, and custom MLflow plugins for CV-specific artifact types. diff --git a/docs/skills/onnx.md b/docs/skills/onnx.md index ec3b096..9895618 100644 --- a/docs/skills/onnx.md +++ b/docs/skills/onnx.md @@ -118,4 +118,4 @@ onnx.save(slimmed, "model.onnx") ## Full Reference -See [`skills/onnx/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/onnx/SKILL.md) for patterns including TensorRT optimization, quantization, model surgery, and batched inference with dynamic shapes. +See [`skills/onnx/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/onnx/SKILL.md) for patterns including TensorRT optimization, quantization, model surgery, and batched inference with dynamic shapes. diff --git a/docs/skills/opencv.md b/docs/skills/opencv.md index 1ddc582..3193cfc 100644 --- a/docs/skills/opencv.md +++ b/docs/skills/opencv.md @@ -118,4 +118,4 @@ def process_video_frames( ## Full Reference -See [`skills/opencv/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/opencv/SKILL.md) for patterns including camera calibration, stereo vision, and high-performance image processing with CUDA-accelerated OpenCV. +See [`skills/opencv/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/opencv/SKILL.md) for patterns including camera calibration, stereo vision, and high-performance image processing with CUDA-accelerated OpenCV. diff --git a/docs/skills/pixi.md b/docs/skills/pixi.md index 2921644..167f375 100644 --- a/docs/skills/pixi.md +++ b/docs/skills/pixi.md @@ -89,4 +89,4 @@ export = { cmd = "python scripts/export_onnx.py", depends-on = ["train"] } ## Full Reference -See [`skills/pixi/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/pixi/SKILL.md) for advanced patterns including multi-platform builds, workspace configurations, and integration with conda-forge for system-level CV libraries. +See [`skills/pixi/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/pixi/SKILL.md) for advanced patterns including multi-platform builds, workspace configurations, and integration with conda-forge for system-level CV libraries. diff --git a/docs/skills/pre-commit.md b/docs/skills/pre-commit.md index 749aaa3..15cfe3b 100644 --- a/docs/skills/pre-commit.md +++ b/docs/skills/pre-commit.md @@ -62,10 +62,10 @@ repos: pixi add pre-commit # Install hooks -pixi run pre-commit install +uv run pre-commit install # Run on all files (first time or CI) -pixi run pre-commit run --all-files +uv run pre-commit run --all-files ``` ## Anti-Patterns to Avoid @@ -84,4 +84,4 @@ pixi run pre-commit run --all-files ## Full Reference -See [`skills/pre-commit/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/pre-commit/SKILL.md) for patterns including custom local hooks for ML-specific validation, commit message linting, and DVC file validation hooks. +See [`skills/pre-commit/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/pre-commit/SKILL.md) for patterns including custom local hooks for ML-specific validation, commit message linting, and DVC file validation hooks. diff --git a/docs/skills/pydantic-strict.md b/docs/skills/pydantic-strict.md index 03b245a..b4002df 100644 --- a/docs/skills/pydantic-strict.md +++ b/docs/skills/pydantic-strict.md @@ -101,4 +101,4 @@ class PredictionResponse(BaseModel, strict=True): ## Full Reference -See [`skills/pydantic-strict/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/pydantic-strict/SKILL.md) for patterns covering custom validators, serialization, and Pydantic settings for environment variable loading. +See [`skills/pydantic-strict/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/pydantic-strict/SKILL.md) for patterns covering custom validators, serialization, and Pydantic settings for environment variable loading. diff --git a/docs/skills/pypi.md b/docs/skills/pypi.md index e54b29c..351b155 100644 --- a/docs/skills/pypi.md +++ b/docs/skills/pypi.md @@ -74,13 +74,13 @@ __version__ = "0.1.0" ```bash # Build the package -pixi run python -m build +uv run python -m build # Check the distribution -pixi run twine check dist/* +uv run twine check dist/* # Publish to PyPI -pixi run twine upload dist/* +uv run twine upload dist/* ``` ### Semantic Release Configuration @@ -119,4 +119,4 @@ prerelease_token = "dev" ## Full Reference -See [`skills/pypi/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/pypi/SKILL.md) for patterns including namespace packages, C extension builds for CV operations, and TestPyPI workflows. +See [`skills/pypi/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/pypi/SKILL.md) for patterns including namespace packages, C extension builds for CV operations, and TestPyPI workflows. diff --git a/docs/skills/pytorch-lightning.md b/docs/skills/pytorch-lightning.md index ead9fa2..c3b3059 100644 --- a/docs/skills/pytorch-lightning.md +++ b/docs/skills/pytorch-lightning.md @@ -112,4 +112,4 @@ class ImageDataModule(L.LightningDataModule): ## Full Reference -See [`skills/pytorch-lightning/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/pytorch-lightning/SKILL.md) for complete patterns including custom callbacks, profiling, and advanced distributed training configuration. +See [`skills/pytorch-lightning/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/pytorch-lightning/SKILL.md) for complete patterns including custom callbacks, profiling, and advanced distributed training configuration. diff --git a/docs/skills/tensorboard.md b/docs/skills/tensorboard.md index c43d4db..48d96b1 100644 --- a/docs/skills/tensorboard.md +++ b/docs/skills/tensorboard.md @@ -89,4 +89,4 @@ with profile( ## Full Reference -See [`skills/tensorboard/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/tensorboard/SKILL.md) for patterns including embedding visualization, custom scalar plugins, and multi-run comparison layouts. +See [`skills/tensorboard/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/tensorboard/SKILL.md) for patterns including embedding visualization, custom scalar plugins, and multi-run comparison layouts. diff --git a/docs/skills/tensorrt.md b/docs/skills/tensorrt.md index 552ddb2..36b99ef 100644 --- a/docs/skills/tensorrt.md +++ b/docs/skills/tensorrt.md @@ -73,4 +73,4 @@ PyTorch → torch.onnx.export() → onnxslim.slim() → trtexec/Builder → .eng ## Full Reference -See [`skills/tensorrt/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/tensorrt/SKILL.md) for Python Builder API, dynamic shape profiles, INT8 calibration, benchmarking utilities, Pydantic configuration, and Dockerfile patterns. +See [`skills/tensorrt/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/tensorrt/SKILL.md) for Python Builder API, dynamic shape profiles, INT8 calibration, benchmarking utilities, Pydantic configuration, and Dockerfile patterns. diff --git a/docs/skills/testing.md b/docs/skills/testing.md index d332f72..640ce05 100644 --- a/docs/skills/testing.md +++ b/docs/skills/testing.md @@ -104,4 +104,4 @@ filterwarnings = ["ignore::DeprecationWarning"] ## Full Reference -See [`skills/testing/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/testing/SKILL.md) for advanced patterns including GPU-conditional tests, snapshot testing for model outputs, and property-based testing with Hypothesis. +See [`skills/testing/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/testing/SKILL.md) for advanced patterns including GPU-conditional tests, snapshot testing for model outputs, and property-based testing with Hypothesis. diff --git a/docs/skills/vscode.md b/docs/skills/vscode.md index 6c75c7d..cb7a807 100644 --- a/docs/skills/vscode.md +++ b/docs/skills/vscode.md @@ -102,4 +102,4 @@ A well-configured editor eliminates friction. This skill teaches Claude Code to ## Full Reference -See [`skills/vscode/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/vscode/SKILL.md) for patterns including Dev Container configuration, multi-root workspaces, and task definitions for common ML workflows. +See [`skills/vscode/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/vscode/SKILL.md) for patterns including Dev Container configuration, multi-root workspaces, and task definitions for common ML workflows. diff --git a/docs/skills/wandb.md b/docs/skills/wandb.md index f835f35..e484d52 100644 --- a/docs/skills/wandb.md +++ b/docs/skills/wandb.md @@ -106,4 +106,4 @@ parameters: ## Full Reference -See [`skills/wandb/SKILL.md`](https://github.com/ortizeg/ai-cv-claude-skills/blob/main/skills/wandb/SKILL.md) for patterns including custom W&B Tables for dataset visualization, model registry integration, and alert configuration for metric thresholds. +See [`skills/wandb/SKILL.md`](https://github.com/ortizeg/whet/blob/main/skills/wandb/SKILL.md) for patterns including custom W&B Tables for dataset visualization, model registry integration, and alert configuration for metric thresholds. diff --git a/justfile b/justfile new file mode 100644 index 0000000..2015671 --- /dev/null +++ b/justfile @@ -0,0 +1,32 @@ +# whet — development task runner + +test: + uv run pytest tests/ -v + +test-cov: + uv run pytest tests/ --cov=src/whet --cov-report=term --cov-report=html + +lint: + uv run ruff check . + +format: + uv run ruff format . + +format-check: + uv run ruff format --check . + +typecheck: + uv run mypy src/whet/ tests/ --strict + +docs-serve: + uv run mkdocs serve + +docs-build: + uv run mkdocs build + +# Run all checks (lint + typecheck + test) +check: lint typecheck test + +# Install whet in development mode +dev: + uv sync --all-extras diff --git a/mkdocs.yml b/mkdocs.yml index 6d399f7..e633471 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,9 +1,9 @@ site_name: AI/CV Claude Skills site_description: Production-ready skills framework for computer vision and deep learning projects site_author: Enrique G. Ortiz -site_url: https://ortizeg.github.io/ai-cv-claude-skills/ -repo_url: https://github.com/ortizeg/ai-cv-claude-skills -repo_name: ortizeg/ai-cv-claude-skills +site_url: https://ortizeg.github.io/whet/ +repo_url: https://github.com/ortizeg/whet +repo_name: ortizeg/whet theme: name: material @@ -84,12 +84,19 @@ nav: - TensorRT: skills/tensorrt.md - Abstraction Patterns: skills/abstraction-patterns.md - Library Review: skills/library-review.md + - FastAPI: skills/fastapi.md + - AWS SageMaker: skills/aws-sagemaker.md + - Hugging Face: skills/huggingface.md + - Gradio: skills/gradio.md + - Kubernetes: skills/kubernetes.md - Agents: - Overview: agents/index.md - Expert Coder: agents/expert-coder.md - ML Engineer: agents/ml-engineer.md - Code Review: agents/code-review.md - Test Engineer: agents/test-engineer.md + - DevOps/Infra: agents/devops-infra.md + - Data Engineer: agents/data-engineer.md - Archetypes: - Overview: archetypes/index.md - PyTorch Training: archetypes/pytorch-training-project.md diff --git a/pixi.lock b/pixi.lock deleted file mode 100644 index ef44601..0000000 --- a/pixi.lock +++ /dev/null @@ -1,1869 +0,0 @@ -version: 6 -environments: - default: - channels: - - url: https://conda.anaconda.org/conda-forge/ - indexes: - - https://pypi.org/simple - options: - pypi-prerelease-mode: if-necessary-or-explicit - packages: - linux-64: - - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.3.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.1.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/coverage-7.13.2-py314h67df5f8_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/griffe-1.15.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_105.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-1.0.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-python-2.0.1-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-1.19.1-py314h5bd0f2a_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.0.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314h0f05182_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.21.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.2-h32b2ec7_101_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.7.8-py314h0f05182_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.14.14-h40fa522_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/watchdog-6.0.0-py314hdafbbf9_2.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - - pypi: https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl - osx-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.3.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-python-1.2.0-py314h3262eb8_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.1.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/coverage-7.13.2-py314h10d0514_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/griffe-1.15.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.8-h3d58e20_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.2-h11316ed_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.2-hb99441e_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-1.0.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-python-2.0.1-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/mypy-1.19.1-py314h6482030_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.1-hb6871ef_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.0.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py314hd330473_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.21.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.2-hf88997e_101_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/python-librt-0.7.8-py314hd330473_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.14.14-h5930b28_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/watchdog-6.0.0-py314h6482030_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - - pypi: https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl - osx-arm64: - - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.3.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.1.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.13.2-py314h6e9b3f0_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/griffe-1.15.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.2-h38cb7af_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.8-hf598326_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.2-h8088a28_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.2-h1ae2325_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-1.0.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-python-2.0.1-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/mypy-1.19.1-py314hbdd0d06_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.1-hd24854e_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.0.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py314ha14b1ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.21.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.2-h40d2674_101_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-librt-0.7.8-py314ha14b1ff_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.14.14-h279115b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchdog-6.0.0-py314h0612a62_2.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - - pypi: https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl -packages: -- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 - sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 - md5: d7c89558ba9fa0495403155b64376d81 - license: None - purls: [] - size: 2562 - timestamp: 1578324546067 -- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 - build_number: 16 - sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 - md5: 73aaf86a425cc6e73fcf236a5a46396d - depends: - - _libgcc_mutex 0.1 conda_forge - - libgomp >=7.5.0 - constrains: - - openmp_impl 9999 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 23621 - timestamp: 1650670423406 -- conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.17.0-pyhd8ed1ab_0.conda - sha256: 1c656a35800b7f57f7371605bc6507c8d3ad60fbaaec65876fce7f73df1fc8ac - md5: 0a01c169f0ab0f91b26e77a3301fbfe4 - depends: - - python >=3.9 - - pytz >=2015.7 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/babel?source=hash-mapping - size: 6938256 - timestamp: 1738490268466 -- conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.3.0-py314h680f03e_0.conda - noarch: generic - sha256: c31ab719d256bc6f89926131e88ecd0f0c5d003fe8481852c6424f4ec6c7eb29 - md5: a2ac7763a9ac75055b68f325d3255265 - depends: - - python >=3.14 - license: BSD-3-Clause AND MIT AND EPL-2.0 - purls: [] - size: 7514 - timestamp: 1767044983590 -- conda: https://conda.anaconda.org/conda-forge/noarch/backrefs-5.8-pyhd8ed1ab_0.conda - sha256: 3a0af23d357a07154645c41d035a4efbd15b7a642db397fa9ea0193fd58ae282 - md5: b16e2595d3a9042aa9d570375978835f - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/backrefs?source=hash-mapping - size: 143810 - timestamp: 1740887689966 -- conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda - sha256: 3ad3500bff54a781c29f16ce1b288b36606e2189d0b0ef2f67036554f47f12b0 - md5: 8910d2c46f7e7b519129f486e0fe927a - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libstdcxx >=14 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - constrains: - - libbrotlicommon 1.2.0 hb03c661_1 - license: MIT - license_family: MIT - purls: - - pkg:pypi/brotli?source=hash-mapping - size: 367376 - timestamp: 1764017265553 -- conda: https://conda.anaconda.org/conda-forge/osx-64/brotli-python-1.2.0-py314h3262eb8_1.conda - sha256: 2e34922abda4ac5726c547887161327b97c3bbd39f1204a5db162526b8b04300 - md5: 389d75a294091e0d7fa5a6fc683c4d50 - depends: - - __osx >=10.13 - - libcxx >=19 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - constrains: - - libbrotlicommon 1.2.0 h8616949_1 - license: MIT - license_family: MIT - purls: - - pkg:pypi/brotli?source=hash-mapping - size: 390153 - timestamp: 1764017784596 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314h3daef5d_1.conda - sha256: 5c2e471fd262fcc3c5a9d5ea4dae5917b885e0e9b02763dbd0f0d9635ed4cb99 - md5: f9501812fe7c66b6548c7fcaa1c1f252 - depends: - - __osx >=11.0 - - libcxx >=19 - - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - - python_abi 3.14.* *_cp314 - constrains: - - libbrotlicommon 1.2.0 hc919400_1 - license: MIT - license_family: MIT - purls: - - pkg:pypi/brotli?source=hash-mapping - size: 359854 - timestamp: 1764018178608 -- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda - sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5 - md5: 51a19bba1b8ebfb60df25cde030b7ebc - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: bzip2-1.0.6 - license_family: BSD - purls: [] - size: 260341 - timestamp: 1757437258798 -- conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_8.conda - sha256: 8f50b58efb29c710f3cecf2027a8d7325ba769ab10c746eff75cea3ac050b10c - md5: 97c4b3bd8a90722104798175a1bdddbf - depends: - - __osx >=10.13 - license: bzip2-1.0.6 - license_family: BSD - purls: [] - size: 132607 - timestamp: 1757437730085 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_8.conda - sha256: b456200636bd5fecb2bec63f7e0985ad2097cf1b83d60ce0b6968dffa6d02aa1 - md5: 58fd217444c2a5701a44244faf518206 - depends: - - __osx >=11.0 - license: bzip2-1.0.6 - license_family: BSD - purls: [] - size: 125061 - timestamp: 1757437486465 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.1.4-hbd8a1cb_0.conda - sha256: b5974ec9b50e3c514a382335efa81ed02b05906849827a34061c496f4defa0b2 - md5: bddacf101bb4dd0e51811cb69c7790e2 - depends: - - __unix - license: ISC - purls: [] - size: 146519 - timestamp: 1767500828366 -- conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.1.4-pyhd8ed1ab_0.conda - sha256: 110338066d194a715947808611b763857c15458f8b3b97197387356844af9450 - md5: eacc711330cd46939f66cd401ff9c44b - depends: - - python >=3.10 - license: ISC - purls: - - pkg:pypi/certifi?source=compressed-mapping - size: 150969 - timestamp: 1767500900768 -- conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.4-pyhd8ed1ab_0.conda - sha256: b32f8362e885f1b8417bac2b3da4db7323faa12d5db62b7fd6691c02d60d6f59 - md5: a22d1fd9bf98827e280a02875d9a007a - depends: - - python >=3.10 - license: MIT - license_family: MIT - purls: - - pkg:pypi/charset-normalizer?source=hash-mapping - size: 50965 - timestamp: 1760437331772 -- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.3.1-pyh8f84b5b_1.conda - sha256: 38cfe1ee75b21a8361c8824f5544c3866f303af1762693a178266d7f198e8715 - md5: ea8a6c3256897cc31263de9f455e25d9 - depends: - - python >=3.10 - - __unix - - python - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/click?source=hash-mapping - size: 97676 - timestamp: 1764518652276 -- conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - sha256: ab29d57dc70786c1269633ba3dff20288b81664d3ff8d21af995742e2bb03287 - md5: 962b9857ee8e7018c22f2776ffa0b2d7 - depends: - - python >=3.9 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/colorama?source=hash-mapping - size: 27011 - timestamp: 1733218222191 -- conda: https://conda.anaconda.org/conda-forge/linux-64/coverage-7.13.2-py314h67df5f8_0.conda - sha256: a39c8e10fa85fb942fbc297cb96f1676cbe9c88c660a18dd6642007e037341fa - md5: ff4ed891a8646b56042ade345ee5c88e - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - tomli - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/coverage?source=hash-mapping - size: 411667 - timestamp: 1769385506528 -- conda: https://conda.anaconda.org/conda-forge/osx-64/coverage-7.13.2-py314h10d0514_0.conda - sha256: 1f914d076e478c62784ba768c648e3b23c1d95475130ca5f519ac63209a21827 - md5: 8a0d5bba423473595e51a29b1336f636 - depends: - - __osx >=10.13 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - tomli - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/coverage?source=hash-mapping - size: 409187 - timestamp: 1769385713298 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.13.2-py314h6e9b3f0_0.conda - sha256: 4bea44a5c8c79a568dc2c7e3c9fcf6733851da9f99d6e31d08cd754675ce5c35 - md5: ffbca80ffff09c0f8cf57784330cac72 - depends: - - __osx >=11.0 - - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - - python_abi 3.14.* *_cp314 - - tomli - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/coverage?source=hash-mapping - size: 411591 - timestamp: 1769385713575 -- conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - sha256: ee6cf346d017d954255bbcbdb424cddea4d14e4ed7e9813e429db1d795d01144 - md5: 8e662bd460bda79b1ea39194e3c4c9ab - depends: - - python >=3.10 - - typing_extensions >=4.6.0 - license: MIT and PSF-2.0 - purls: - - pkg:pypi/exceptiongroup?source=hash-mapping - size: 21333 - timestamp: 1763918099466 -- conda: https://conda.anaconda.org/conda-forge/noarch/ghp-import-2.1.0-pyhd8ed1ab_2.conda - sha256: 40fdf5a9d5cc7a3503cd0c33e1b90b1e6eab251aaaa74e6b965417d089809a15 - md5: 93f742fe078a7b34c29a182958d4d765 - depends: - - python >=3.9 - - python-dateutil >=2.8.1 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/ghp-import?source=hash-mapping - size: 16538 - timestamp: 1734344477841 -- conda: https://conda.anaconda.org/conda-forge/noarch/griffe-1.15.0-pyhd8ed1ab_0.conda - sha256: 7a42213d6a6dae486c56a835a107fc8704eabf686a2b95adde009e06848426cd - md5: 0bc57a76679376b93a489824aa08c294 - depends: - - colorama >=0.4 - - python >=3.10 - license: ISC - purls: - - pkg:pypi/griffe?source=hash-mapping - size: 114576 - timestamp: 1762806629967 -- conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda - sha256: 84c64443368f84b600bfecc529a1194a3b14c3656ee2e832d15a20e0329b6da3 - md5: 164fc43f0b53b6e3a7bc7dce5e4f1dc9 - depends: - - python >=3.10 - - hyperframe >=6.1,<7 - - hpack >=4.1,<5 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/h2?source=hash-mapping - size: 95967 - timestamp: 1756364871835 -- conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda - sha256: 6ad78a180576c706aabeb5b4c8ceb97c0cb25f1e112d76495bff23e3779948ba - md5: 0a802cb9888dd14eeefc611f05c40b6e - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/hpack?source=hash-mapping - size: 30731 - timestamp: 1737618390337 -- conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - sha256: 77af6f5fe8b62ca07d09ac60127a30d9069fdc3c68d6b256754d0ffb1f7779f8 - md5: 8e6923fc12f1fe8f8c4e5c9f343256ac - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/hyperframe?source=hash-mapping - size: 17397 - timestamp: 1737618427549 -- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.2-h33c6efd_0.conda - sha256: 142a722072fa96cf16ff98eaaf641f54ab84744af81754c292cb81e0881c0329 - md5: 186a18e3ba246eccfc7cff00cd19a870 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libstdcxx >=14 - license: MIT - license_family: MIT - purls: [] - size: 12728445 - timestamp: 1767969922681 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.2-h38cb7af_0.conda - sha256: d4cefbca587429d1192509edc52c88de52bc96c2447771ddc1f8bee928aed5ef - md5: 1e93aca311da0210e660d2247812fa02 - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - purls: [] - size: 12358010 - timestamp: 1767970350308 -- conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.11-pyhd8ed1ab_0.conda - sha256: ae89d0299ada2a3162c2614a9d26557a92aa6a77120ce142f8e0109bbf0342b0 - md5: 53abe63df7e10a6ba605dc5f9f961d36 - depends: - - python >=3.10 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/idna?source=hash-mapping - size: 50721 - timestamp: 1760286526795 -- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-8.7.0-pyhe01879c_1.conda - sha256: c18ab120a0613ada4391b15981d86ff777b5690ca461ea7e9e49531e8f374745 - md5: 63ccfdc3a3ce25b027b8767eb722fca8 - depends: - - python >=3.9 - - zipp >=3.20 - - python - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/importlib-metadata?source=hash-mapping - size: 34641 - timestamp: 1747934053147 -- conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - sha256: e1a9e3b1c8fe62dc3932a616c284b5d8cbe3124bbfbedcf4ce5c828cb166ee19 - md5: 9614359868482abba1bd15ce465e3c42 - depends: - - python >=3.10 - license: MIT - license_family: MIT - purls: - - pkg:pypi/iniconfig?source=compressed-mapping - size: 13387 - timestamp: 1760831448842 -- conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - sha256: fc9ca7348a4f25fed2079f2153ecdcf5f9cf2a0bc36c4172420ca09e1849df7b - md5: 04558c96691bed63104678757beb4f8d - depends: - - markupsafe >=2.0 - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/jinja2?source=compressed-mapping - size: 120685 - timestamp: 1764517220861 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45-default_hbd61a6d_105.conda - sha256: 1027bd8aa0d5144e954e426ab6218fd5c14e54a98f571985675468b339c808ca - md5: 3ec0aa5037d39b06554109a01e6fb0c6 - depends: - - __glibc >=2.17,<3.0.a0 - - zstd >=1.5.7,<1.6.0a0 - constrains: - - binutils_impl_linux-64 2.45 - license: GPL-3.0-only - license_family: GPL - purls: [] - size: 730831 - timestamp: 1766513089214 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-21.1.8-h3d58e20_0.conda - sha256: cbd8e821e97436d8fc126c24b50df838b05ba4c80494fbb93ccaf2e3b2d109fb - md5: 9f8a60a77ecafb7966ca961c94f33bd1 - depends: - - __osx >=10.13 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - purls: [] - size: 569777 - timestamp: 1765919624323 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-21.1.8-hf598326_0.conda - sha256: 82e228975fd491bcf1071ecd0a6ec2a0fcc5f57eb0bd1d52cb13a18d57c67786 - md5: 780f0251b757564e062187044232c2b7 - depends: - - __osx >=11.0 - license: Apache-2.0 WITH LLVM-exception - license_family: Apache - purls: [] - size: 569118 - timestamp: 1765919724254 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.3-hecca717_0.conda - sha256: 1e1b08f6211629cbc2efe7a5bca5953f8f6b3cae0eeb04ca4dacee1bd4e2db2f - md5: 8b09ae86839581147ef2e5c5e229d164 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - constrains: - - expat 2.7.3.* - license: MIT - license_family: MIT - purls: [] - size: 76643 - timestamp: 1763549731408 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.7.3-heffb93a_0.conda - sha256: d11b3a6ce5b2e832f430fd112084533a01220597221bee16d6c7dc3947dffba6 - md5: 222e0732a1d0780a622926265bee14ef - depends: - - __osx >=10.13 - constrains: - - expat 2.7.3.* - license: MIT - license_family: MIT - purls: [] - size: 74058 - timestamp: 1763549886493 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.7.3-haf25636_0.conda - sha256: fce22610ecc95e6d149e42a42fbc3cc9d9179bd4eb6232639a60f06e080eec98 - md5: b79875dbb5b1db9a4a22a4520f918e1a - depends: - - __osx >=11.0 - constrains: - - expat 2.7.3.* - license: MIT - license_family: MIT - purls: [] - size: 67800 - timestamp: 1763549994166 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda - sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 - md5: a360c33a5abe61c07959e449fa1453eb - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: MIT - license_family: MIT - purls: [] - size: 58592 - timestamp: 1769456073053 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda - sha256: 951958d1792238006fdc6fce7f71f1b559534743b26cc1333497d46e5903a2d6 - md5: 66a0dc7464927d0853b590b6f53ba3ea - depends: - - __osx >=10.13 - license: MIT - license_family: MIT - purls: [] - size: 53583 - timestamp: 1769456300951 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda - sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 - md5: 43c04d9cb46ef176bb2a4c77e324d599 - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - purls: [] - size: 40979 - timestamp: 1769456747661 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_16.conda - sha256: 6eed58051c2e12b804d53ceff5994a350c61baf117ec83f5f10c953a3f311451 - md5: 6d0363467e6ed84f11435eb309f2ff06 - depends: - - __glibc >=2.17,<3.0.a0 - - _openmp_mutex >=4.5 - constrains: - - libgcc-ng ==15.2.0=*_16 - - libgomp 15.2.0 he0feb66_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 1042798 - timestamp: 1765256792743 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_16.conda - sha256: 5b3e5e4e9270ecfcd48f47e3a68f037f5ab0f529ccb223e8e5d5ac75a58fc687 - md5: 26c46f90d0e727e95c6c9498a33a09f3 - depends: - - __glibc >=2.17,<3.0.a0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 603284 - timestamp: 1765256703881 -- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda - sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb - md5: c7c83eecbb72d88b940c249af56c8b17 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - constrains: - - xz 5.8.2.* - license: 0BSD - purls: [] - size: 113207 - timestamp: 1768752626120 -- conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.2-h11316ed_0.conda - sha256: 7ab3c98abd3b5d5ec72faa8d9f5d4b50dcee4970ed05339bc381861199dabb41 - md5: 688a0c3d57fa118b9c97bf7e471ab46c - depends: - - __osx >=10.13 - constrains: - - xz 5.8.2.* - license: 0BSD - purls: [] - size: 105482 - timestamp: 1768753411348 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.2-h8088a28_0.conda - sha256: 7bfc7ffb2d6a9629357a70d4eadeadb6f88fa26ebc28f606b1c1e5e5ed99dc7e - md5: 009f0d956d7bfb00de86901d16e486c7 - depends: - - __osx >=11.0 - constrains: - - xz 5.8.2.* - license: 0BSD - purls: [] - size: 92242 - timestamp: 1768752982486 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda - sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 - md5: 2c21e66f50753a083cbe6b80f38268fa - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 92400 - timestamp: 1769482286018 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda - sha256: 1096c740109386607938ab9f09a7e9bca06d86770a284777586d6c378b8fb3fd - md5: ec88ba8a245855935b871a7324373105 - depends: - - __osx >=10.13 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 79899 - timestamp: 1769482558610 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda - sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 - md5: 57c4be259f5e0b99a5983799a228ae55 - depends: - - __osx >=11.0 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 73690 - timestamp: 1769482560514 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.51.2-hf4e2dac_0.conda - sha256: 04596fcee262a870e4b7c9807224680ff48d4d0cc0dac076a602503d3dc6d217 - md5: da5be73701eecd0e8454423fd6ffcf30 - depends: - - __glibc >=2.17,<3.0.a0 - - icu >=78.2,<79.0a0 - - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 - license: blessing - purls: [] - size: 942808 - timestamp: 1768147973361 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.51.2-hb99441e_0.conda - sha256: 710a7ea27744199023c92e66ad005de7f8db9cf83f10d5a943d786f0dac53b7c - md5: d910105ce2b14dfb2b32e92ec7653420 - depends: - - __osx >=10.13 - - libzlib >=1.3.1,<2.0a0 - license: blessing - purls: [] - size: 987506 - timestamp: 1768148247615 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.51.2-h1ae2325_0.conda - sha256: 6e9b9f269732cbc4698c7984aa5b9682c168e2a8d1e0406e1ff10091ca046167 - md5: 4b0bf313c53c3e89692f020fb55d5f2c - depends: - - __osx >=11.0 - - icu >=78.2,<79.0a0 - - libzlib >=1.3.1,<2.0a0 - license: blessing - purls: [] - size: 909777 - timestamp: 1768148320535 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_16.conda - sha256: 813427918316a00c904723f1dfc3da1bbc1974c5cfe1ed1e704c6f4e0798cbc6 - md5: 68f68355000ec3f1d6f26ea13e8f525f - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc 15.2.0 he0feb66_16 - constrains: - - libstdcxx-ng ==15.2.0=*_16 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 5856456 - timestamp: 1765256838573 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda - sha256: 1a7539cfa7df00714e8943e18de0b06cceef6778e420a5ee3a2a145773758aee - md5: db409b7c1720428638e7c0d509d3e1b5 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 40311 - timestamp: 1766271528534 -- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda - sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4 - md5: edb0dca6bc32e4f4789199455a1dbeb8 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - constrains: - - zlib 1.3.1 *_2 - license: Zlib - license_family: Other - purls: [] - size: 60963 - timestamp: 1727963148474 -- conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.1-hd23fc13_2.conda - sha256: 8412f96504fc5993a63edf1e211d042a1fd5b1d51dedec755d2058948fcced09 - md5: 003a54a4e32b02f7355b50a837e699da - depends: - - __osx >=10.13 - constrains: - - zlib 1.3.1 *_2 - license: Zlib - license_family: Other - purls: [] - size: 57133 - timestamp: 1727963183990 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.1-h8359307_2.conda - sha256: ce34669eadaba351cd54910743e6a2261b67009624dbc7daeeafdef93616711b - md5: 369964e85dc26bfe78f41399b366c435 - depends: - - __osx >=11.0 - constrains: - - zlib 1.3.1 *_2 - license: Zlib - license_family: Other - purls: [] - size: 46438 - timestamp: 1727963202283 -- conda: https://conda.anaconda.org/conda-forge/noarch/markdown-3.10.1-pyhcf101f3_0.conda - sha256: 40e45b3e57a6b3c61e9ca0bf8526fec9814dca2be4115245966946e1192983ef - md5: 8a5f9a177a2ea05c66237e6f2eaece60 - depends: - - importlib-metadata >=4.4 - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/markdown?source=compressed-mapping - size: 85359 - timestamp: 1769079901411 -- conda: https://conda.anaconda.org/conda-forge/noarch/markupsafe-3.0.3-pyh7db6752_0.conda - sha256: e0cbfea51a19b3055ca19428bd9233a25adca956c208abb9d00b21e7259c7e03 - md5: fab1be106a50e20f10fe5228fd1d1651 - depends: - - python >=3.10 - constrains: - - jinja2 >=3.0.0 - track_features: - - markupsafe_no_compile - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/markupsafe?source=hash-mapping - size: 15499 - timestamp: 1759055275624 -- conda: https://conda.anaconda.org/conda-forge/noarch/mergedeep-1.3.4-pyhd8ed1ab_1.conda - sha256: e5b555fd638334a253d83df14e3c913ef8ce10100090e17fd6fb8e752d36f95d - md5: d9a8fc1f01deae61735c88ec242e855c - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mergedeep?source=hash-mapping - size: 11676 - timestamp: 1734157119152 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-1.6.1-pyhd8ed1ab_1.conda - sha256: 902d2e251f9a7ffa7d86a3e62be5b2395e28614bd4dbe5f50acf921fd64a8c35 - md5: 14661160be39d78f2b210f2cc2766059 - depends: - - click >=7.0 - - colorama >=0.4 - - ghp-import >=1.0 - - importlib-metadata >=4.4 - - jinja2 >=2.11.1 - - markdown >=3.3.6 - - markupsafe >=2.0.1 - - mergedeep >=1.3.4 - - mkdocs-get-deps >=0.2.0 - - packaging >=20.5 - - pathspec >=0.11.1 - - python >=3.9 - - pyyaml >=5.1 - - pyyaml-env-tag >=0.1 - - watchdog >=2.0 - constrains: - - babel >=2.9.0 - license: BSD-2-Clause - license_family: BSD - purls: - - pkg:pypi/mkdocs?source=hash-mapping - size: 3524754 - timestamp: 1734344673481 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-autorefs-1.4.3-pyhd8ed1ab_0.conda - sha256: 1631568d0d36bc182ec20c5b4c58cc053cdd77698b4741977776f592996d345b - md5: 1c024504ac97f1199023327a69066a8f - depends: - - markdown >=3.3 - - markupsafe >=2.0.1 - - mkdocs >=1.1 - - pymdown-extensions - - python >=3.10 - license: ISC - purls: - - pkg:pypi/mkdocs-autorefs?source=hash-mapping - size: 35016 - timestamp: 1756236211878 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-get-deps-0.2.0-pyhd8ed1ab_1.conda - sha256: e0b501b96f7e393757fb2a61d042015966f6c5e9ac825925e43f9a6eafa907b6 - md5: 84382acddb26c27c70f2de8d4c830830 - depends: - - importlib-metadata >=4.3 - - mergedeep >=1.3.4 - - platformdirs >=2.2.0 - - python >=3.9 - - pyyaml >=5.1 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mkdocs-get-deps?source=hash-mapping - size: 14757 - timestamp: 1734353035244 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-9.7.1-pyhcf101f3_0.conda - sha256: e3c9ad7beece49540a4de5a9a3136081af64ceae0745336819a8c40a9e25f336 - md5: ab5cf0f1cd513e87bbd5736bdc13a399 - depends: - - python >=3.10 - - jinja2 >=3.0,<4.dev0 - - markdown >=3.2,<4.dev0 - - mkdocs >=1.6,<2.dev0 - - mkdocs-material-extensions >=1.3,<2.dev0 - - pygments >=2.16,<3.dev0 - - pymdown-extensions >=10.2,<11.dev0 - - babel >=2.10,<3.dev0 - - colorama >=0.4,<1.dev0 - - paginate >=0.5,<1.dev0 - - backrefs >=5.7.post1,<6.dev0 - - requests >=2.26,<3.dev0 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/mkdocs-material?source=hash-mapping - size: 4795211 - timestamp: 1766061978730 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocs-material-extensions-1.3.1-pyhd8ed1ab_1.conda - sha256: f62955d40926770ab65cc54f7db5fde6c073a3ba36a0787a7a5767017da50aa3 - md5: de8af4000a4872e16fb784c649679c8e - depends: - - python >=3.9 - constrains: - - mkdocs-material >=5.0.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mkdocs-material-extensions?source=hash-mapping - size: 16122 - timestamp: 1734641109286 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-1.0.2-pyhd8ed1ab_0.conda - sha256: fab2631edd28edb7dcc4d95981e5864229206f5b287173b4bd3f623567e66e5c - md5: c5ae381fcc4c236f5790e45d4b6ed952 - depends: - - click >=7.0 - - importlib-metadata >=4.6 - - jinja2 >=3.1 - - markdown >=3.6 - - markupsafe >=1.1 - - mkdocs >=1.6 - - mkdocs-autorefs >=1.4 - - pymdown-extensions >=6.3 - - python >=3.10,<4.0 - - typing-extensions >=4.1 - license: ISC - purls: - - pkg:pypi/mkdocstrings?source=hash-mapping - size: 36268 - timestamp: 1769350161953 -- conda: https://conda.anaconda.org/conda-forge/noarch/mkdocstrings-python-2.0.1-pyh332efcf_0.conda - sha256: d0bc1c036bc47028c6a52184a54df21aaa30eb4dbe1a47fb011d0834bc588ea1 - md5: ba61f0f35d5938db303a2174f52f4307 - depends: - - griffe >=1.13 - - mkdocs-autorefs >=1.4 - - mkdocstrings >=0.30 - - python >=3.10 - - typing_extensions >=4.0 - license: ISC - purls: - - pkg:pypi/mkdocstrings-python?source=hash-mapping - size: 57172 - timestamp: 1764784251344 -- conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-1.19.1-py314h5bd0f2a_0.conda - sha256: 4e607095b92cac2ec6dbb8de348d8e006408291c9c2805926f01e4a30e94edbb - md5: 0490f2b08d179719201fdb9514d67157 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - mypy_extensions >=1.0.0 - - pathspec >=0.9.0 - - psutil >=4.0 - - python >=3.14,<3.15.0a0 - - python-librt >=0.6.2 - - python_abi 3.14.* *_cp314 - - typing_extensions >=4.6.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mypy?source=hash-mapping - size: 18632958 - timestamp: 1765795548407 -- conda: https://conda.anaconda.org/conda-forge/osx-64/mypy-1.19.1-py314h6482030_0.conda - sha256: b36d3a5728413d18dedfecdfd0248647b21f3e725547c03ef245bc1c08da98f8 - md5: 62c7130c7f42ab43c9d1d64bbc7c2f3e - depends: - - __osx >=10.13 - - mypy_extensions >=1.0.0 - - pathspec >=0.9.0 - - psutil >=4.0 - - python >=3.14,<3.15.0a0 - - python-librt >=0.6.2 - - python_abi 3.14.* *_cp314 - - typing_extensions >=4.6.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mypy?source=hash-mapping - size: 12043718 - timestamp: 1765796036801 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/mypy-1.19.1-py314hbdd0d06_0.conda - sha256: c5c9a691dc00ce9a726426f971fbe21d0501ec8c6228513b945210898f26c761 - md5: 584f58048dc4af70f6c647b40a7049a6 - depends: - - __osx >=11.0 - - mypy_extensions >=1.0.0 - - pathspec >=0.9.0 - - psutil >=4.0 - - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - - python-librt >=0.6.2 - - python_abi 3.14.* *_cp314 - - typing_extensions >=4.6.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mypy?source=hash-mapping - size: 11320681 - timestamp: 1765795843941 -- conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda - sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 - md5: e9c622e0d00fa24a6292279af3ab6d06 - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/mypy-extensions?source=hash-mapping - size: 11766 - timestamp: 1745776666688 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda - sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586 - md5: 47e340acb35de30501a76c7c799c41d7 - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=13 - license: X11 AND BSD-3-Clause - purls: [] - size: 891641 - timestamp: 1738195959188 -- conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.5-h0622a9a_3.conda - sha256: ea4a5d27ded18443749aefa49dc79f6356da8506d508b5296f60b8d51e0c4bd9 - md5: ced34dd9929f491ca6dab6a2927aff25 - depends: - - __osx >=10.13 - license: X11 AND BSD-3-Clause - purls: [] - size: 822259 - timestamp: 1738196181298 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.5-h5e97a16_3.conda - sha256: 2827ada40e8d9ca69a153a45f7fd14f32b2ead7045d3bbb5d10964898fe65733 - md5: 068d497125e4bf8a66bf707254fff5ae - depends: - - __osx >=11.0 - license: X11 AND BSD-3-Clause - purls: [] - size: 797030 - timestamp: 1738196177597 -- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda - sha256: 44c877f8af015332a5d12f5ff0fb20ca32f896526a7d0cdb30c769df1144fb5c - md5: f61eb8cd60ff9057122a3d338b99c00f - depends: - - __glibc >=2.17,<3.0.a0 - - ca-certificates - - libgcc >=14 - license: Apache-2.0 - license_family: Apache - purls: [] - size: 3164551 - timestamp: 1769555830639 -- conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.1-hb6871ef_1.conda - sha256: e02e5639b0e4d6d4fcf0f3b082642844fb5a37316f5b0a1126c6271347462e90 - md5: 30bb8d08b99b9a7600d39efb3559fff0 - depends: - - __osx >=10.13 - - ca-certificates - license: Apache-2.0 - license_family: Apache - purls: [] - size: 2777136 - timestamp: 1769557662405 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.1-hd24854e_1.conda - sha256: 361f5c5e60052abc12bdd1b50d7a1a43e6a6653aab99a2263bf2288d709dcf67 - md5: f4f6ad63f98f64191c3e77c5f5f29d76 - depends: - - __osx >=11.0 - - ca-certificates - license: Apache-2.0 - license_family: Apache - purls: [] - size: 3104268 - timestamp: 1769556384749 -- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda - sha256: c1fc0f953048f743385d31c468b4a678b3ad20caffdeaa94bed85ba63049fd58 - md5: b76541e68fea4d511b1ac46a28dcd2c6 - depends: - - python >=3.8 - - python - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/packaging?source=compressed-mapping - size: 72010 - timestamp: 1769093650580 -- conda: https://conda.anaconda.org/conda-forge/noarch/paginate-0.5.7-pyhd8ed1ab_1.conda - sha256: f6fef1b43b0d3d92476e1870c08d7b9c229aebab9a0556b073a5e1641cf453bd - md5: c3f35453097faf911fd3f6023fc2ab24 - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/paginate?source=hash-mapping - size: 18865 - timestamp: 1734618649164 -- conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.0.4-pyhd8ed1ab_0.conda - sha256: 29ea20d0faf20374fcd61c25f6d32fb8e9a2c786a7f1473a0c3ead359470fbe1 - md5: 2908273ac396d2cd210a8127f5f1c0d6 - depends: - - python >=3.10 - license: MPL-2.0 - purls: - - pkg:pypi/pathspec?source=compressed-mapping - size: 53739 - timestamp: 1769677743677 -- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.5.1-pyhcf101f3_0.conda - sha256: 04c64fb78c520e5c396b6e07bc9082735a5cc28175dbe23138201d0a9441800b - md5: 1bd2e65c8c7ef24f4639ae6e850dacc2 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/platformdirs?source=hash-mapping - size: 23922 - timestamp: 1764950726246 -- conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - sha256: e14aafa63efa0528ca99ba568eaf506eb55a0371d12e6250aaaa61718d2eb62e - md5: d7585b6550ad04c8c5e21097ada2888e - depends: - - python >=3.9 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/pluggy?source=compressed-mapping - size: 25877 - timestamp: 1764896838868 -- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314h0f05182_0.conda - sha256: f15574ed6c8c8ed8c15a0c5a00102b1efe8b867c0bd286b498cd98d95bd69ae5 - md5: 4f225a966cfee267a79c5cb6382bd121 - depends: - - python - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - - python_abi 3.14.* *_cp314 - license: BSD-3-Clause - purls: - - pkg:pypi/psutil?source=compressed-mapping - size: 231303 - timestamp: 1769678156552 -- conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py314hd330473_0.conda - sha256: 3194ce0d94c810cb1809da851261be34e1cae72ca345445b29e61766b38ee6cc - md5: d465805e603072c341554159939be5b8 - depends: - - python - - __osx >=10.13 - - python_abi 3.14.* *_cp314 - license: BSD-3-Clause - purls: - - pkg:pypi/psutil?source=hash-mapping - size: 242816 - timestamp: 1769678225798 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py314ha14b1ff_0.conda - sha256: e0f31c053eb11803d63860c213b2b1b57db36734f5f84a3833606f7c91fedff9 - md5: fc4c7ab223873eee32080d51600ce7e7 - depends: - - python - - __osx >=11.0 - - python 3.14.* *_cp314 - - python_abi 3.14.* *_cp314 - license: BSD-3-Clause - purls: - - pkg:pypi/psutil?source=compressed-mapping - size: 245502 - timestamp: 1769678303655 -- conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda - sha256: 5577623b9f6685ece2697c6eb7511b4c9ac5fb607c9babc2646c811b428fd46a - md5: 6b6ece66ebcae2d5f326c77ef2c5a066 - depends: - - python >=3.9 - license: BSD-2-Clause - license_family: BSD - purls: - - pkg:pypi/pygments?source=hash-mapping - size: 889287 - timestamp: 1750615908735 -- conda: https://conda.anaconda.org/conda-forge/noarch/pymdown-extensions-10.21.1-pyhd8ed1ab_0.conda - sha256: 405e0a5e43ccf7c1cd084932e97291c3cf934036e8569e9cc0ccc91371aae93b - md5: d08476fd9134bf9a37edb06c4d65f441 - depends: - - markdown >=3.6 - - python >=3.10 - - pyyaml - license: MIT - license_family: MIT - purls: - - pkg:pypi/pymdown-extensions?source=hash-mapping - size: 171234 - timestamp: 1769322477196 -- conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda - sha256: ba3b032fa52709ce0d9fd388f63d330a026754587a2f461117cac9ab73d8d0d8 - md5: 461219d1a5bd61342293efa2c0c90eac - depends: - - __unix - - python >=3.9 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/pysocks?source=hash-mapping - size: 21085 - timestamp: 1733217331982 -- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda - sha256: 9e749fb465a8bedf0184d8b8996992a38de351f7c64e967031944978de03a520 - md5: 2b694bad8a50dc2f712f5368de866480 - depends: - - pygments >=2.7.2 - - python >=3.10 - - iniconfig >=1.0.1 - - packaging >=22 - - pluggy >=1.5,<2 - - tomli >=1 - - colorama >=0.4 - - exceptiongroup >=1 - - python - constrains: - - pytest-faulthandler >=2 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pytest?source=hash-mapping - size: 299581 - timestamp: 1765062031645 -- conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - sha256: d0f45586aad48ef604590188c33c83d76e4fc6370ac569ba0900906b24fd6a26 - md5: 6891acad5e136cb62a8c2ed2679d6528 - depends: - - coverage >=7.10.6 - - pluggy >=1.2 - - pytest >=7 - - python >=3.10 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/pytest-cov?source=hash-mapping - size: 29016 - timestamp: 1757612051022 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.2-h32b2ec7_101_cp314.conda - build_number: 101 - sha256: 24719868a471dd94041aa9873c6f87adf3b86c07878ad4e242ac97228f9e6460 - md5: 051f60a9d1e3aae7160d173aeb7029f8 - depends: - - __glibc >=2.17,<3.0.a0 - - bzip2 >=1.0.8,<2.0a0 - - ld_impl_linux-64 >=2.36.1 - - libexpat >=2.7.3,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - libgcc >=14 - - liblzma >=5.8.2,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.51.2,<4.0a0 - - libuuid >=2.41.3,<3.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.4,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.3,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - zstd >=1.5.7,<1.6.0a0 - license: Python-2.0 - purls: [] - size: 36833080 - timestamp: 1769458770373 - python_site_packages_path: lib/python3.14/site-packages -- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.14.2-hf88997e_101_cp314.conda - build_number: 101 - sha256: ed30feda6d392462c11a1bf39821e24c505bc206d94cf2d8e615e30f8a0813d1 - md5: 4e6019945cd98f2d10bc87852a598a48 - depends: - - __osx >=10.13 - - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.3,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.2,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.51.2,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.4,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.3,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - zstd >=1.5.7,<1.6.0a0 - license: Python-2.0 - purls: [] - size: 14366719 - timestamp: 1769459230339 - python_site_packages_path: lib/python3.14/site-packages -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.14.2-h40d2674_101_cp314.conda - build_number: 101 - sha256: 0b3ae49a61b8baf1af8133e8dac0d404a66b634000de0760c8fb692a5ce86e84 - md5: f0999777d0ec086b14d68ed02a143b94 - depends: - - __osx >=11.0 - - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.3,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.2,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.51.2,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - ncurses >=6.5,<7.0a0 - - openssl >=3.5.4,<4.0a0 - - python_abi 3.14.* *_cp314 - - readline >=8.3,<9.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - zstd >=1.5.7,<1.6.0a0 - license: Python-2.0 - purls: [] - size: 12570588 - timestamp: 1769459207868 - python_site_packages_path: lib/python3.14/site-packages -- conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - sha256: d6a17ece93bbd5139e02d2bd7dbfa80bee1a4261dced63f65f679121686bf664 - md5: 5b8d21249ff20967101ffa321cab24e8 - depends: - - python >=3.9 - - six >=1.5 - - python - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/python-dateutil?source=hash-mapping - size: 233310 - timestamp: 1751104122689 -- conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.7.8-py314h0f05182_0.conda - sha256: 7c4615367e1d8bee1e98abcfccd742fb0c382a150f21cb592a66af69063eae43 - md5: 1cdbb8798d700d90f33998d41baed1ec - depends: - - python - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/librt?source=hash-mapping - size: 64072 - timestamp: 1768406896488 -- conda: https://conda.anaconda.org/conda-forge/osx-64/python-librt-0.7.8-py314hd330473_0.conda - sha256: 9cbd3910f22d3c44a1635cc2646df218eedb4b97dc232db6f24ea4f93d271755 - md5: 2d35a795767f06747bba198e529c31c7 - depends: - - python - - __osx >=10.13 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/librt?source=hash-mapping - size: 57536 - timestamp: 1768406920191 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-librt-0.7.8-py314ha14b1ff_0.conda - sha256: 3af72999955913d5f1f1eec62ff5080ff14e44435f527e273e310a5e3e7682d9 - md5: 89c4e1720ff541a874b45fe06b4e1e1e - depends: - - python - - __osx >=11.0 - - python 3.14.* *_cp314 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/librt?source=hash-mapping - size: 65713 - timestamp: 1768407001203 -- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - build_number: 8 - sha256: ad6d2e9ac39751cc0529dd1566a26751a0bf2542adb0c232533d32e176e21db5 - md5: 0539938c55b6b1a59b560e843ad864a4 - constrains: - - python 3.14.* *_cp314 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 6989 - timestamp: 1752805904792 -- conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda - sha256: 8d2a8bf110cc1fc3df6904091dead158ba3e614d8402a83e51ed3a8aa93cdeb0 - md5: bc8e3267d44011051f2eb14d22fb0960 - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pytz?source=hash-mapping - size: 189015 - timestamp: 1742920947249 -- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - sha256: 828af2fd7bb66afc9ab1c564c2046be391aaf66c0215f05afaf6d7a9a270fe2a - md5: b12f41c0d7fb5ab81709fcc86579688f - depends: - - python >=3.10.* - - yaml - track_features: - - pyyaml_no_compile - license: MIT - license_family: MIT - purls: - - pkg:pypi/pyyaml?source=hash-mapping - size: 45223 - timestamp: 1758891992558 -- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-env-tag-1.1-pyhd8ed1ab_0.conda - sha256: 69ab63bd45587406ae911811fc4d4c1bf972d643fa57a009de7c01ac978c4edd - md5: e8e53c4150a1bba3b160eacf9d53a51b - depends: - - python >=3.9 - - pyyaml - license: MIT - license_family: MIT - purls: - - pkg:pypi/pyyaml-env-tag?source=hash-mapping - size: 11137 - timestamp: 1747237061448 -- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda - sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 - md5: d7d95fc8287ea7bf33e0e7116d2b95ec - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - purls: [] - size: 345073 - timestamp: 1765813471974 -- conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda - sha256: 4614af680aa0920e82b953fece85a03007e0719c3399f13d7de64176874b80d5 - md5: eefd65452dfe7cce476a519bece46704 - depends: - - __osx >=10.13 - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - purls: [] - size: 317819 - timestamp: 1765813692798 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda - sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 - md5: f8381319127120ce51e081dce4865cf4 - depends: - - __osx >=11.0 - - ncurses >=6.5,<7.0a0 - license: GPL-3.0-only - license_family: GPL - purls: [] - size: 313930 - timestamp: 1765813902568 -- conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda - sha256: 7813c38b79ae549504b2c57b3f33394cea4f2ad083f0994d2045c2e24cb538c5 - md5: c65df89a0b2e321045a9e01d1337b182 - depends: - - python >=3.10 - - certifi >=2017.4.17 - - charset-normalizer >=2,<4 - - idna >=2.5,<4 - - urllib3 >=1.21.1,<3 - - python - constrains: - - chardet >=3.0.2,<6 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/requests?source=compressed-mapping - size: 63602 - timestamp: 1766926974520 -- conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.14.14-h40fa522_1.conda - noarch: python - sha256: 0c6c9825ff88195fd13936d63872213d6c88c1fe795d136881c0753c3037c5ff - md5: d3e1d08b141529c7fce6a13b4d670605 - depends: - - python - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - constrains: - - __glibc >=2.17 - license: MIT - license_family: MIT - purls: - - pkg:pypi/ruff?source=compressed-mapping - size: 9131490 - timestamp: 1769520999080 -- conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.14.14-h5930b28_1.conda - noarch: python - sha256: af08c2b449d39d0fa37870ad803b68a5377a6b2559d5496870990e3ba543ffbf - md5: 8bba83a6d1877c251c3c95a8f7e8c1f0 - depends: - - python - - __osx >=10.13 - constrains: - - __osx >=10.13 - license: MIT - license_family: MIT - purls: - - pkg:pypi/ruff?source=hash-mapping - size: 9088594 - timestamp: 1769521251218 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.14.14-h279115b_1.conda - noarch: python - sha256: 20f93b70375e6ad43ec507611cf28814277be17a2794a2a94e2df13a0b34f8d3 - md5: bcc5ef166c4de9a225c9ca9cb4fa631e - depends: - - python - - __osx >=11.0 - constrains: - - __osx >=11.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/ruff?source=compressed-mapping - size: 8337249 - timestamp: 1769521105071 -- conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - sha256: 458227f759d5e3fcec5d9b7acce54e10c9e1f4f4b7ec978f3bfd54ce4ee9853d - md5: 3339e3b65d58accf4ca4fb8748ab16b3 - depends: - - python >=3.9 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/six?source=hash-mapping - size: 18455 - timestamp: 1753199211006 -- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda - sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac - md5: cffd3bdd58090148f4cfcd831f4b26ab - depends: - - __glibc >=2.17,<3.0.a0 - - libgcc >=14 - - libzlib >=1.3.1,<2.0a0 - constrains: - - xorg-libx11 >=1.8.12,<2.0a0 - license: TCL - license_family: BSD - purls: [] - size: 3301196 - timestamp: 1769460227866 -- conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda - sha256: 7f0d9c320288532873e2d8486c331ec6d87919c9028208d3f6ac91dc8f99a67b - md5: 6e6efb7463f8cef69dbcb4c2205bf60e - depends: - - __osx >=10.13 - - libzlib >=1.3.1,<2.0a0 - license: TCL - license_family: BSD - purls: [] - size: 3282953 - timestamp: 1769460532442 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda - sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 - md5: a9d86bc62f39b94c4661716624eb21b0 - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: TCL - license_family: BSD - purls: [] - size: 3127137 - timestamp: 1769460817696 -- conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda - sha256: 62940c563de45790ba0f076b9f2085a842a65662268b02dd136a8e9b1eaf47a8 - md5: 72e780e9aa2d0a3295f59b1874e3768b - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/tomli?source=compressed-mapping - size: 21453 - timestamp: 1768146676791 -- pypi: https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl - name: types-pyyaml - version: 6.0.12.20250915 - sha256: e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6 - requires_python: '>=3.9' -- conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.15.0-h396c80c_0.conda - sha256: 7c2df5721c742c2a47b2c8f960e718c930031663ac1174da67c1ed5999f7938c - md5: edd329d7d3a4ab45dcf905899a7a6115 - depends: - - typing_extensions ==4.15.0 pyhcf101f3_0 - license: PSF-2.0 - license_family: PSF - purls: [] - size: 91383 - timestamp: 1756220668932 -- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda - sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 - md5: 0caa1af407ecff61170c9437a808404d - depends: - - python >=3.10 - - python - license: PSF-2.0 - license_family: PSF - purls: - - pkg:pypi/typing-extensions?source=hash-mapping - size: 51692 - timestamp: 1756220668932 -- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda - sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c - md5: ad659d0a2b3e47e38d829aa8cad2d610 - license: LicenseRef-Public-Domain - purls: [] - size: 119135 - timestamp: 1767016325805 -- conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.6.3-pyhd8ed1ab_0.conda - sha256: af641ca7ab0c64525a96fd9ad3081b0f5bcf5d1cbb091afb3f6ed5a9eee6111a - md5: 9272daa869e03efe68833e3dc7a02130 - depends: - - backports.zstd >=1.0.0 - - brotli-python >=1.2.0 - - h2 >=4,<5 - - pysocks >=1.5.6,<2.0,!=1.5.7 - - python >=3.10 - license: MIT - license_family: MIT - purls: - - pkg:pypi/urllib3?source=hash-mapping - size: 103172 - timestamp: 1767817860341 -- conda: https://conda.anaconda.org/conda-forge/linux-64/watchdog-6.0.0-py314hdafbbf9_2.conda - sha256: 45d141a99385d53bde38db3f8a45534f8401f27f4cb23a5c5c58f83599e574de - md5: 63ecab5c42d962e2c5bfdab8252c0880 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - pyyaml >=3.10 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/watchdog?source=hash-mapping - size: 151798 - timestamp: 1763021736811 -- conda: https://conda.anaconda.org/conda-forge/osx-64/watchdog-6.0.0-py314h6482030_2.conda - sha256: 17bbadf45780e904fc6ee25a2ed15e5623fcb1d8df089274fd76d0bc47b0e840 - md5: cdcbdf97ab294fd0650c93885901c067 - depends: - - __osx >=10.13 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - pyyaml >=3.10 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/watchdog?source=hash-mapping - size: 158871 - timestamp: 1763021924013 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/watchdog-6.0.0-py314h0612a62_2.conda - sha256: ebbed86961de90524033b92a6d8938d73116cda198fc8ff58c08f905f226ecca - md5: ebf26848dda35b5223483d7fe6677e1c - depends: - - __osx >=11.0 - - python >=3.14,<3.15.0a0 - - python >=3.14,<3.15.0a0 *_cp314 - - python_abi 3.14.* *_cp314 - - pyyaml >=3.10 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/watchdog?source=hash-mapping - size: 159429 - timestamp: 1763022276083 -- conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda - sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad - md5: a77f85f77be52ff59391544bfe73390a - depends: - - libgcc >=14 - - __glibc >=2.17,<3.0.a0 - license: MIT - license_family: MIT - purls: [] - size: 85189 - timestamp: 1753484064210 -- conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda - sha256: a335161bfa57b64e6794c3c354e7d49449b28b8d8a7c4ed02bf04c3f009953f9 - md5: a645bb90997d3fc2aea0adf6517059bd - depends: - - __osx >=10.13 - license: MIT - license_family: MIT - purls: [] - size: 79419 - timestamp: 1753484072608 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda - sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac - md5: 78a0fe9e9c50d2c381e8ee47e3ea437d - depends: - - __osx >=11.0 - license: MIT - license_family: MIT - purls: [] - size: 83386 - timestamp: 1753484079473 -- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.23.0-pyhcf101f3_1.conda - sha256: b4533f7d9efc976511a73ef7d4a2473406d7f4c750884be8e8620b0ce70f4dae - md5: 30cd29cb87d819caead4d55184c1d115 - depends: - - python >=3.10 - - python - license: MIT - license_family: MIT - purls: - - pkg:pypi/zipp?source=hash-mapping - size: 24194 - timestamp: 1764460141901 -- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 - md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 - depends: - - __glibc >=2.17,<3.0.a0 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 601375 - timestamp: 1764777111296 -- conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-h3eecb57_6.conda - sha256: 47101a4055a70a4876ffc87b750ab2287b67eca793f21c8224be5e1ee6394d3f - md5: 727109b184d680772e3122f40136d5ca - depends: - - __osx >=10.13 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 528148 - timestamp: 1764777156963 -- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - sha256: 9485ba49e8f47d2b597dd399e88f4802e100851b27c21d7525625b0b4025a5d9 - md5: ab136e4c34e97f34fb621d2592a393d8 - depends: - - __osx >=11.0 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 433413 - timestamp: 1764777166076 diff --git a/pixi.toml b/pixi.toml deleted file mode 100644 index 2818074..0000000 --- a/pixi.toml +++ /dev/null @@ -1,29 +0,0 @@ -[workspace] -name = "ai-cv-claude-skills" -version = "0.1.0" -description = "Production-ready skills for AI/CV projects with Claude Code" -authors = ["Enrique G. Ortiz "] -channels = ["conda-forge"] -platforms = ["linux-64", "osx-64", "osx-arm64"] - -[dependencies] -python = ">=3.11" -pytest = ">=7.4" -pytest-cov = ">=4.1" -ruff = ">=0.8" -mypy = ">=1.11" -mkdocs-material = ">=9.5" -mkdocstrings = ">=0.24" -mkdocstrings-python = ">=1.11" - -[pypi-dependencies] -types-PyYAML = ">=6.0" - -[tasks] -test = "pytest tests/ -v" -test-cov = "pytest tests/ --cov=. --cov-report=term --cov-report=html" -lint = "ruff check ." -format = "ruff format ." -typecheck = "mypy tests/ --strict" -docs-serve = "mkdocs serve" -docs-build = "mkdocs build" diff --git a/pyproject.toml b/pyproject.toml index 88aa33f..0a92fcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,23 +1,83 @@ [project] -name = "ai-cv-claude-skills" +name = "whet" version = "0.1.0" -description = "Production-ready skills for AI/CV projects with Claude Code" +description = "Sharpen your AI coder — install expert skills into AI coding agents" authors = [{name = "Enrique G. Ortiz", email = "ortizeg@gmail.com"}] readme = "README.md" requires-python = ">=3.11" license = {text = "MIT"} +keywords = ["ai", "skills", "claude", "agents", "cli", "computer-vision", "deep-learning"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Code Generators", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed", +] + +dependencies = [ + "typer>=0.15", + "rich>=13.0", + "pydantic>=2.0", + "tomli>=2.0; python_version < '3.12'", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4", + "pytest-cov>=4.1", + "ruff>=0.8", + "mypy>=1.11", + "types-PyYAML>=6.0", +] +docs = [ + "mkdocs-material>=9.5", + "mkdocstrings>=0.24", + "mkdocstrings-python>=1.11", +] + +[project.urls] +Homepage = "https://github.com/ortizeg/whet" +Documentation = "https://ortizeg.github.io/whet/" +Repository = "https://github.com/ortizeg/whet" +Issues = "https://github.com/ortizeg/whet/issues" + +[project.scripts] +whet = "whet.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/whet"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/whet/", + "skills/", + "agents/", + "archetypes/", + "settings/", +] [tool.ruff] line-length = 100 target-version = "py311" -exclude = [".pixi"] +exclude = [".pixi", ".venv", "archetypes/*/template"] [tool.ruff.lint] select = ["E", "F", "I", "N", "UP", "S", "B", "A", "C4", "T20", "SIM"] -ignore = ["S101"] +ignore = ["S101", "B008", "UP042"] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["S101", "T201"] +"src/whet/cli/**/*.py" = ["B008"] [tool.mypy] python_version = "3.11" @@ -25,6 +85,23 @@ strict = true warn_return_any = true warn_unused_ignores = true +[[tool.mypy.overrides]] +module = ["typer", "typer.*"] +ignore_missing_imports = true +disallow_untyped_decorators = false + +[[tool.mypy.overrides]] +module = ["whet.cli.*"] +disallow_untyped_decorators = false + +[[tool.mypy.overrides]] +module = ["yaml"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["tomli"] +ignore_missing_imports = true + [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] diff --git a/settings.local.json b/settings.local.json index 053f690..acc8ab3 100644 --- a/settings.local.json +++ b/settings.local.json @@ -1,48 +1,46 @@ { "permissions": { "allow": [ - "Bash(git *)", - "Bash(gh *)", - "Bash(gcloud *)", - "Bash(docker *)", - "Bash(pixi *)", - "Bash(*/pixi *)", - "Bash(python *)", - "Bash(python3 *)", - "Bash(pip *)", - "Bash(pip3 *)", - "Bash(pytest *)", - "Bash(*/pytest *)", - "Bash(ruff *)", - "Bash(*/ruff *)", - "Bash(mypy *)", - "Bash(*/mypy *)", - "Bash(ls *)", - "Bash(cat *)", - "Bash(mkdir *)", - "Bash(cp *)", - "Bash(mv *)", - "Bash(rm *)", - "Bash(chmod *)", - "Bash(which *)", - "Bash(wc *)", - "Bash(diff *)", - "Bash(head *)", - "Bash(tail *)", - "Bash(sort *)", - "Bash(uniq *)", - "Bash(find *)", - "Bash(grep *)", - "Bash(echo *)", - "Bash(curl *)", - "Bash(wget *)", - "Bash(npm *)", - "Bash(npx *)", + "Bash(git *)", "Bash(gh *)", "Bash(gcloud *)", + "Bash(docker *)", "Bash(docker-compose *)", + "Bash(uv *)", "Bash(uvx *)", + "Bash(python *)", "Bash(python3 *)", + "Bash(pip *)", "Bash(pip3 *)", + "Bash(pytest *)", "Bash(*/pytest *)", + "Bash(ruff *)", "Bash(*/ruff *)", + "Bash(mypy *)", "Bash(*/mypy *)", + "Bash(mkdocs *)", "Bash(just *)", + "Bash(ls)", "Bash(ls *)", + "Bash(pwd)", + "Bash(tree)", "Bash(tree *)", + "Bash(cat *)", "Bash(head *)", "Bash(tail *)", + "Bash(wc)", "Bash(wc *)", + "Bash(mkdir *)", "Bash(cp *)", "Bash(mv *)", "Bash(rm *)", + "Bash(touch *)", "Bash(ln *)", "Bash(chmod *)", + "Bash(which)", "Bash(which *)", + "Bash(type *)", "Bash(command *)", + "Bash(diff *)", "Bash(sort *)", "Bash(uniq *)", + "Bash(find *)", "Bash(grep *)", + "Bash(echo *)", "Bash(printf *)", + "Bash(curl *)", "Bash(wget *)", + "Bash(npm *)", "Bash(npx *)", "Bash(node *)", + "Bash(realpath *)", "Bash(dirname *)", "Bash(basename *)", + "Bash(stat *)", "Bash(file *)", + "Bash(env *)", + "Bash(printenv)", "Bash(printenv *)", + "Bash(uname)", "Bash(uname *)", + "Bash(date)", "Bash(date *)", + "Bash(tee *)", "Bash(xargs *)", + "Bash(sed *)", "Bash(awk *)", "Bash(cut *)", "Bash(tr *)", + "Bash(test *)", "Bash([ *)", + "Bash(true)", "Bash(false)", "WebFetch(domain:github.com)", "WebFetch(domain:raw.githubusercontent.com)", "WebFetch(domain:pypi.org)", "WebFetch(domain:pytorch.org)", - "WebFetch(domain:docs.python.org)" + "WebFetch(domain:docs.python.org)", + "WebFetch(domain:huggingface.co)", + "WebFetch(domain:stackoverflow.com)" ] } -} \ No newline at end of file +} diff --git a/settings/antigravity.json b/settings/antigravity.json new file mode 100644 index 0000000..7651666 --- /dev/null +++ b/settings/antigravity.json @@ -0,0 +1,39 @@ +{ + "permissions": { + "allow": [ + "Bash(git *)", "Bash(gh *)", "Bash(gcloud *)", + "Bash(docker *)", "Bash(docker-compose *)", + "Bash(uv *)", "Bash(uvx *)", + "Bash(python *)", "Bash(python3 *)", + "Bash(pip *)", "Bash(pip3 *)", + "Bash(pytest *)", "Bash(*/pytest *)", + "Bash(ruff *)", "Bash(*/ruff *)", + "Bash(mypy *)", "Bash(*/mypy *)", + "Bash(mkdocs *)", "Bash(just *)", + "Bash(ls)", "Bash(ls *)", + "Bash(pwd)", + "Bash(tree)", "Bash(tree *)", + "Bash(cat *)", "Bash(head *)", "Bash(tail *)", + "Bash(wc)", "Bash(wc *)", + "Bash(mkdir *)", "Bash(cp *)", "Bash(mv *)", "Bash(rm *)", + "Bash(touch *)", "Bash(ln *)", "Bash(chmod *)", + "Bash(which)", "Bash(which *)", + "Bash(type *)", "Bash(command *)", + "Bash(diff *)", "Bash(sort *)", "Bash(uniq *)", + "Bash(find *)", "Bash(grep *)", + "Bash(echo *)", "Bash(printf *)", + "Bash(curl *)", "Bash(wget *)", + "Bash(npm *)", "Bash(npx *)", "Bash(node *)", + "Bash(realpath *)", "Bash(dirname *)", "Bash(basename *)", + "Bash(stat *)", "Bash(file *)", + "Bash(env *)", + "Bash(printenv)", "Bash(printenv *)", + "Bash(uname)", "Bash(uname *)", + "Bash(date)", "Bash(date *)", + "Bash(tee *)", "Bash(xargs *)", + "Bash(sed *)", "Bash(awk *)", "Bash(cut *)", "Bash(tr *)", + "Bash(test *)", "Bash([ *)", + "Bash(true)", "Bash(false)" + ] + } +} diff --git a/settings/base.json b/settings/base.json new file mode 100644 index 0000000..2a2defc --- /dev/null +++ b/settings/base.json @@ -0,0 +1,30 @@ +{ + "permissions": { + "allow": [ + "Bash(git *)", "Bash(gh *)", + "Bash(uv *)", "Bash(uvx *)", + "Bash(python *)", "Bash(python3 *)", + "Bash(pip *)", "Bash(pip3 *)", + "Bash(pytest *)", "Bash(*/pytest *)", + "Bash(ruff *)", "Bash(*/ruff *)", + "Bash(mypy *)", "Bash(*/mypy *)", + "Bash(just *)", + "Bash(ls)", "Bash(ls *)", + "Bash(pwd)", + "Bash(cat *)", "Bash(head *)", "Bash(tail *)", + "Bash(wc)", "Bash(wc *)", + "Bash(mkdir *)", "Bash(cp *)", "Bash(mv *)", "Bash(rm *)", + "Bash(touch *)", "Bash(ln *)", "Bash(chmod *)", + "Bash(which)", "Bash(which *)", + "Bash(diff *)", "Bash(sort *)", "Bash(uniq *)", + "Bash(find *)", "Bash(grep *)", + "Bash(echo *)", "Bash(printf *)", + "Bash(realpath *)", "Bash(dirname *)", "Bash(basename *)", + "Bash(stat *)", "Bash(file *)", + "Bash(env *)", + "Bash(printenv)", "Bash(printenv *)", + "Bash(test *)", "Bash([ *)", + "Bash(true)", "Bash(false)" + ] + } +} diff --git a/settings/claude.json b/settings/claude.json new file mode 100644 index 0000000..1d95120 --- /dev/null +++ b/settings/claude.json @@ -0,0 +1,48 @@ +{ + "permissions": { + "allow": [ + "Bash(git *)", "Bash(gh *)", "Bash(gcloud *)", + "Bash(docker *)", "Bash(docker-compose *)", + "Bash(uv *)", "Bash(uvx *)", + "Bash(python *)", "Bash(python3 *)", + "Bash(pip *)", "Bash(pip3 *)", + "Bash(pytest *)", "Bash(*/pytest *)", + "Bash(ruff *)", "Bash(*/ruff *)", + "Bash(mypy *)", "Bash(*/mypy *)", + "Bash(mkdocs *)", "Bash(just *)", + "Bash(ls)", "Bash(ls *)", + "Bash(pwd)", + "Bash(tree)", "Bash(tree *)", + "Bash(cat *)", "Bash(head *)", "Bash(tail *)", + "Bash(wc)", "Bash(wc *)", + "Bash(mkdir *)", "Bash(cp *)", "Bash(mv *)", "Bash(rm *)", + "Bash(touch *)", "Bash(ln *)", "Bash(chmod *)", + "Bash(which)", "Bash(which *)", + "Bash(type *)", "Bash(command *)", + "Bash(diff *)", "Bash(sort *)", "Bash(uniq *)", + "Bash(find *)", "Bash(grep *)", + "Bash(echo *)", "Bash(printf *)", + "Bash(curl *)", "Bash(wget *)", + "Bash(npm *)", "Bash(npx *)", "Bash(node *)", + "Bash(realpath *)", "Bash(dirname *)", "Bash(basename *)", + "Bash(stat *)", "Bash(file *)", + "Bash(env *)", + "Bash(printenv)", "Bash(printenv *)", + "Bash(uname)", "Bash(uname *)", + "Bash(date)", "Bash(date *)", + "Bash(tee *)", "Bash(xargs *)", + "Bash(sed *)", "Bash(awk *)", "Bash(cut *)", "Bash(tr *)", + "Bash(test *)", "Bash([ *)", + "Bash(true)", "Bash(false)", + "WebFetch(domain:github.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:pypi.org)", + "WebFetch(domain:pytorch.org)", + "WebFetch(domain:docs.python.org)", + "WebFetch(domain:huggingface.co)", + "WebFetch(domain:stackoverflow.com)", + "WebFetch(domain:npmjs.com)", + "WebFetch(domain:developer.mozilla.org)" + ] + } +} diff --git a/skills/abstraction-patterns/SKILL.md b/skills/abstraction-patterns/SKILL.md index c9e8e47..89849b2 100644 --- a/skills/abstraction-patterns/SKILL.md +++ b/skills/abstraction-patterns/SKILL.md @@ -1,3 +1,11 @@ +--- +name: abstraction-patterns +description: > + Teaches when and how to create well-designed abstractions in AI/CV Python projects. + Covers the Rule of Three, interface design, wrapper patterns, and anti-patterns + for reducing cognitive load without over-engineering. +--- + # Abstraction Patterns Skill You are writing well-abstracted Python code for AI/CV projects. Follow these patterns exactly. diff --git a/skills/abstraction-patterns/skill.toml b/skills/abstraction-patterns/skill.toml new file mode 100644 index 0000000..f9e97a9 --- /dev/null +++ b/skills/abstraction-patterns/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "abstraction-patterns" +version = "1.0.0" +category = "core" +tags = ["design-patterns", "abc", "protocol", "architecture"] + +[dependencies] +requires = ["pydantic-strict"] +recommends = ["master-skill"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/aws-sagemaker/README.md b/skills/aws-sagemaker/README.md new file mode 100644 index 0000000..885fd2b --- /dev/null +++ b/skills/aws-sagemaker/README.md @@ -0,0 +1,32 @@ +# AWS SageMaker Skill + +The AWS SageMaker Skill provides expert patterns for building ML training and deployment pipelines on Amazon SageMaker, covering PyTorch estimators, training script entry points, real-time inference endpoints, batch transform jobs, hyperparameter tuning, SageMaker Pipelines for end-to-end orchestration, and S3 data management. + +## Purpose + +When you need to train models at scale on AWS infrastructure or deploy models behind managed endpoints, SageMaker is the standard managed ML platform. This skill encodes best practices for structuring SageMaker projects: proper estimator configuration, custom inference handlers, pipeline definitions with conditional model registration, and local mode testing to validate scripts before submitting expensive cloud jobs. + +## When to Use + +- When training models on AWS GPU instances (ml.g5, ml.p4, ml.trn1). +- When deploying models as real-time SageMaker endpoints with auto-scaling. +- When building automated ML pipelines with preprocessing, training, evaluation, and model registration. +- When running hyperparameter tuning jobs to optimize model performance. +- When processing large datasets with SageMaker Processing jobs. + +## Key Features + +- **PyTorch Estimator patterns** — proper configuration with distributed training support, instance selection, and hyperparameter passing. +- **Training scripts** — SageMaker-compatible entry points that read `SM_CHANNEL_*` and `SM_MODEL_DIR` environment variables. +- **Custom inference handlers** — `model_fn`, `input_fn`, `predict_fn`, `output_fn` for flexible serving. +- **SageMaker Pipelines** — end-to-end workflow orchestration with conditional model registration based on metrics. +- **Hyperparameter tuning** — Bayesian optimization with metric definitions and parameter ranges. +- **Local mode testing** — validate training scripts with `instance_type="local"` before cloud submission. + +## Related Skills + +- **[PyTorch Lightning](../pytorch-lightning/)** — LightningModule patterns used inside SageMaker training scripts. +- **[Docker CV](../docker-cv/)** — custom training container images when default SageMaker images are insufficient. +- **[W&B](../wandb/)** / **[MLflow](../mlflow/)** — experiment tracking inside SageMaker training containers. +- **[DVC](../dvc/)** — data versioning with S3 remote storage. +- **[GCP](../gcp/)** — alternative cloud platform patterns for comparison. diff --git a/skills/aws-sagemaker/SKILL.md b/skills/aws-sagemaker/SKILL.md new file mode 100644 index 0000000..4067854 --- /dev/null +++ b/skills/aws-sagemaker/SKILL.md @@ -0,0 +1,624 @@ +--- +name: aws-sagemaker +description: > + AWS SageMaker patterns for ML training and deployment. Covers training jobs, + real-time endpoints, batch transform, processing jobs, model registry, + hyperparameter tuning, SageMaker Pipelines, and S3 data management. +--- + +# AWS SageMaker Skill + +You are building ML training and deployment pipelines on AWS SageMaker. Follow these patterns exactly. + +## Core Philosophy + +SageMaker provides managed infrastructure for training, tuning, and deploying ML models at scale. Every cloud training and deployment workflow targeting AWS uses SageMaker. Use the SageMaker Python SDK for all interactions — never call low-level boto3 APIs for SageMaker operations unless the SDK does not support a feature. + +## Project Structure + +### Standard SageMaker Project Layout + +``` +project/ +├── src/ +│ ├── training/ +│ │ ├── train.py # Training entry point +│ │ ├── model.py # Model definition +│ │ └── data.py # Data loading +│ ├── inference/ +│ │ ├── inference.py # model_fn, input_fn, predict_fn, output_fn +│ │ └── requirements.txt # Inference dependencies +│ └── processing/ +│ └── preprocess.py # Processing job script +├── pipelines/ +│ ├── training_pipeline.py # SageMaker Pipeline definition +│ └── config.py # Pipeline configuration +├── configs/ +│ ├── training.yaml # Hyperparameters +│ └── infrastructure.yaml # Instance types, counts +└── tests/ + ├── test_training_local.py # Local mode tests + └── test_inference.py # Endpoint tests +``` + +## Training Jobs + +### Configuring a Training Job with PyTorch Estimator + +```python +"""SageMaker training job configuration.""" + +from __future__ import annotations + +from pathlib import Path + +import sagemaker +from pydantic import BaseModel, Field +from sagemaker.pytorch import PyTorch + + +class TrainingConfig(BaseModel, frozen=True): + """Training job configuration.""" + + role: str + instance_type: str = "ml.g5.2xlarge" + instance_count: int = 1 + max_run_seconds: int = 86400 + volume_size_gb: int = 100 + output_s3_uri: str + base_job_name: str = "cv-training" + framework_version: str = "2.1.0" + py_version: str = "py310" + + +class HyperParameters(BaseModel, frozen=True): + """Training hyperparameters passed to the training script.""" + + epochs: int = 50 + batch_size: int = 32 + learning_rate: float = 1e-3 + weight_decay: float = 1e-4 + model_name: str = "resnet50" + num_classes: int = 10 + + +def create_estimator( + config: TrainingConfig, + hyperparameters: HyperParameters, +) -> PyTorch: + """Create a SageMaker PyTorch estimator.""" + return PyTorch( + entry_point="train.py", + source_dir="src/training", + role=config.role, + instance_type=config.instance_type, + instance_count=config.instance_count, + framework_version=config.framework_version, + py_version=config.py_version, + output_path=config.output_s3_uri, + base_job_name=config.base_job_name, + max_run=config.max_run_seconds, + volume_size=config.volume_size_gb, + hyperparameters=hyperparameters.model_dump(), + environment={ + "NCCL_DEBUG": "INFO", + "TORCH_DISTRIBUTED_DEBUG": "DETAIL", + }, + distribution={ + "torch_distributed": {"enabled": True} + } if config.instance_count > 1 else None, + ) + + +def launch_training( + config: TrainingConfig, + hyperparameters: HyperParameters, + train_s3_uri: str, + val_s3_uri: str, +) -> str: + """Launch a SageMaker training job and return the job name.""" + estimator = create_estimator(config, hyperparameters) + + estimator.fit( + inputs={ + "train": train_s3_uri, + "validation": val_s3_uri, + }, + wait=False, + ) + + return estimator.latest_training_job.name +``` + +### Training Script Entry Point + +```python +"""SageMaker training script entry point (src/training/train.py).""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +import torch +import torch.distributed as dist +from loguru import logger + + +def parse_args() -> argparse.Namespace: + """Parse SageMaker-injected arguments.""" + parser = argparse.ArgumentParser() + + # Hyperparameters + parser.add_argument("--epochs", type=int, default=50) + parser.add_argument("--batch-size", type=int, default=32) + parser.add_argument("--learning-rate", type=float, default=1e-3) + parser.add_argument("--model-name", type=str, default="resnet50") + parser.add_argument("--num-classes", type=int, default=10) + + # SageMaker environment variables + parser.add_argument("--model-dir", type=str, default=os.environ.get("SM_MODEL_DIR", "/opt/ml/model")) + parser.add_argument("--train", type=str, default=os.environ.get("SM_CHANNEL_TRAIN", "/opt/ml/input/data/train")) + parser.add_argument("--validation", type=str, default=os.environ.get("SM_CHANNEL_VALIDATION", "/opt/ml/input/data/validation")) + parser.add_argument("--output-data-dir", type=str, default=os.environ.get("SM_OUTPUT_DATA_DIR", "/opt/ml/output/data")) + + return parser.parse_args() + + +def train(args: argparse.Namespace) -> None: + """Main training function.""" + logger.info("Starting training with args: {}", vars(args)) + + # Distributed setup + world_size = int(os.environ.get("SM_NUM_GPUS", 1)) + local_rank = int(os.environ.get("LOCAL_RANK", 0)) + + if world_size > 1: + dist.init_process_group(backend="nccl") + torch.cuda.set_device(local_rank) + logger.info("Distributed training: rank {} of {}", local_rank, world_size) + + device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu") + + model = build_model(args.model_name, args.num_classes).to(device) + if world_size > 1: + model = torch.nn.parallel.DistributedDataParallel(model, device_ids=[local_rank]) + + # ... training loop ... + + # Save model artifacts + model_path = Path(args.model_dir) / "model.pth" + torch.save(model.state_dict(), model_path) + logger.info("Model saved to {}", model_path) + + # Save metrics + metrics = {"final_val_loss": 0.25, "final_val_acc": 0.92} + metrics_path = Path(args.output_data_dir) / "metrics.json" + metrics_path.write_text(json.dumps(metrics)) + + +if __name__ == "__main__": + args = parse_args() + train(args) +``` + +## Real-Time Endpoints + +### Deploying a Model Endpoint + +```python +"""SageMaker endpoint deployment.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field +from sagemaker.pytorch import PyTorchModel + + +class EndpointConfig(BaseModel, frozen=True): + """Endpoint deployment configuration.""" + + role: str + instance_type: str = "ml.g5.xlarge" + instance_count: int = 1 + endpoint_name: str + model_data_s3: str + framework_version: str = "2.1.0" + py_version: str = "py310" + + +def deploy_endpoint(config: EndpointConfig) -> str: + """Deploy a real-time inference endpoint.""" + model = PyTorchModel( + model_data=config.model_data_s3, + role=config.role, + framework_version=config.framework_version, + py_version=config.py_version, + entry_point="inference.py", + source_dir="src/inference", + ) + + predictor = model.deploy( + initial_instance_count=config.instance_count, + instance_type=config.instance_type, + endpoint_name=config.endpoint_name, + ) + + return predictor.endpoint_name +``` + +### Custom Inference Script + +```python +"""Custom inference handlers (src/inference/inference.py). + +SageMaker calls these functions in order: + input_fn → predict_fn → output_fn +""" + +from __future__ import annotations + +import io +import json + +import numpy as np +import torch +from PIL import Image +from torchvision import transforms + + +def model_fn(model_dir: str) -> torch.nn.Module: + """Load the trained model from the model directory.""" + model = build_model("resnet50", num_classes=10) + model.load_state_dict(torch.load(f"{model_dir}/model.pth", map_location="cpu")) + model.eval() + return model.cuda() if torch.cuda.is_available() else model + + +def input_fn(request_body: bytes, content_type: str) -> torch.Tensor: + """Deserialize input data to a tensor.""" + if content_type == "application/x-image": + image = Image.open(io.BytesIO(request_body)).convert("RGB") + transform = transforms.Compose([ + transforms.Resize((224, 224)), + transforms.ToTensor(), + transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), + ]) + return transform(image).unsqueeze(0) + elif content_type == "application/json": + data = json.loads(request_body) + return torch.tensor(data["instances"]) + else: + raise ValueError(f"Unsupported content type: {content_type}") + + +def predict_fn(input_data: torch.Tensor, model: torch.nn.Module) -> dict: + """Run inference on the input tensor.""" + device = next(model.parameters()).device + input_data = input_data.to(device) + + with torch.no_grad(): + outputs = model(input_data) + probabilities = torch.softmax(outputs, dim=1) + predictions = torch.argmax(probabilities, dim=1) + + return { + "predictions": predictions.cpu().numpy().tolist(), + "probabilities": probabilities.cpu().numpy().tolist(), + } + + +def output_fn(prediction: dict, accept: str) -> str: + """Serialize prediction output.""" + if accept == "application/json": + return json.dumps(prediction) + raise ValueError(f"Unsupported accept type: {accept}") +``` + +## Hyperparameter Tuning + +### Automatic Model Tuning + +```python +"""SageMaker hyperparameter tuning job.""" + +from __future__ import annotations + +from sagemaker.tuner import ( + CategoricalParameter, + ContinuousParameter, + HyperparameterTuner, + IntegerParameter, +) + + +def create_tuner(estimator: PyTorch) -> HyperparameterTuner: + """Create a hyperparameter tuning job.""" + hyperparameter_ranges = { + "learning-rate": ContinuousParameter(1e-5, 1e-2, scaling_type="Logarithmic"), + "batch-size": CategoricalParameter([16, 32, 64, 128]), + "weight-decay": ContinuousParameter(1e-6, 1e-2, scaling_type="Logarithmic"), + "epochs": IntegerParameter(10, 100), + } + + return HyperparameterTuner( + estimator=estimator, + objective_metric_name="validation:accuracy", + hyperparameter_ranges=hyperparameter_ranges, + metric_definitions=[ + {"Name": "validation:accuracy", "Regex": r"val_acc=(\S+)"}, + {"Name": "validation:loss", "Regex": r"val_loss=(\S+)"}, + ], + max_jobs=20, + max_parallel_jobs=4, + strategy="Bayesian", + objective_type="Maximize", + ) +``` + +## SageMaker Pipelines + +### End-to-End Training Pipeline + +```python +"""SageMaker Pipeline for training and registration.""" + +from __future__ import annotations + +import sagemaker +from sagemaker.processing import ProcessingInput, ProcessingOutput, ScriptProcessor +from sagemaker.pytorch import PyTorch +from sagemaker.workflow.condition_step import ConditionStep +from sagemaker.workflow.conditions import ConditionGreaterThanOrEqualTo +from sagemaker.workflow.functions import JsonGet +from sagemaker.workflow.model_step import ModelStep +from sagemaker.workflow.parameters import ParameterFloat, ParameterString +from sagemaker.workflow.pipeline import Pipeline +from sagemaker.workflow.steps import ProcessingStep, TrainingStep + + +def create_pipeline( + role: str, + pipeline_name: str = "cv-training-pipeline", +) -> Pipeline: + """Create a SageMaker Pipeline.""" + session = sagemaker.Session() + + # Pipeline parameters + input_data = ParameterString(name="InputData") + accuracy_threshold = ParameterFloat(name="AccuracyThreshold", default_value=0.90) + + # Step 1: Data preprocessing + processor = ScriptProcessor( + role=role, + image_uri=session.sagemaker_client.describe_image("pytorch-training")["ImageUri"], + instance_type="ml.m5.xlarge", + instance_count=1, + command=["python3"], + ) + + preprocess_step = ProcessingStep( + name="PreprocessData", + processor=processor, + inputs=[ProcessingInput(source=input_data, destination="/opt/ml/processing/input")], + outputs=[ + ProcessingOutput(output_name="train", source="/opt/ml/processing/output/train"), + ProcessingOutput(output_name="val", source="/opt/ml/processing/output/val"), + ], + code="src/processing/preprocess.py", + ) + + # Step 2: Training + estimator = PyTorch( + entry_point="train.py", + source_dir="src/training", + role=role, + instance_type="ml.g5.2xlarge", + instance_count=1, + framework_version="2.1.0", + py_version="py310", + ) + + training_step = TrainingStep( + name="TrainModel", + estimator=estimator, + inputs={ + "train": preprocess_step.properties.ProcessingOutputConfig.Outputs["train"].S3Output.S3Uri, + "validation": preprocess_step.properties.ProcessingOutputConfig.Outputs["val"].S3Output.S3Uri, + }, + ) + + # Step 3: Conditional model registration + accuracy_condition = ConditionGreaterThanOrEqualTo( + left=JsonGet( + step_name=training_step.name, + property_file="metrics", + json_path="val_accuracy", + ), + right=accuracy_threshold, + ) + + register_step = ModelStep( + name="RegisterModel", + step_args=estimator.register( + content_types=["application/json", "application/x-image"], + response_types=["application/json"], + model_package_group_name="cv-models", + approval_status="PendingManualApproval", + ), + ) + + condition_step = ConditionStep( + name="CheckAccuracy", + conditions=[accuracy_condition], + if_steps=[register_step], + else_steps=[], + ) + + return Pipeline( + name=pipeline_name, + parameters=[input_data, accuracy_threshold], + steps=[preprocess_step, training_step, condition_step], + sagemaker_session=session, + ) +``` + +## S3 Data Management + +### Data Upload and Download Patterns + +```python +"""S3 data management for SageMaker workflows.""" + +from __future__ import annotations + +from pathlib import Path + +import boto3 +import sagemaker +from loguru import logger + + +def upload_dataset( + local_path: Path, + bucket: str, + prefix: str = "datasets", +) -> str: + """Upload a local dataset to S3 and return the S3 URI.""" + session = sagemaker.Session() + s3_uri = session.upload_data( + path=str(local_path), + bucket=bucket, + key_prefix=prefix, + ) + logger.info("Uploaded {} to {}", local_path, s3_uri) + return s3_uri + + +def download_model_artifacts( + model_data_s3: str, + local_dir: Path, +) -> Path: + """Download model artifacts from S3.""" + local_dir.mkdir(parents=True, exist_ok=True) + + session = boto3.Session() + s3 = session.resource("s3") + + # Parse S3 URI + parts = model_data_s3.replace("s3://", "").split("/", 1) + bucket_name, key = parts[0], parts[1] + + local_path = local_dir / Path(key).name + s3.Bucket(bucket_name).download_file(key, str(local_path)) + + logger.info("Downloaded {} to {}", model_data_s3, local_path) + return local_path +``` + +## Batch Transform + +### Offline Batch Inference + +```python +"""SageMaker batch transform for offline inference.""" + +from __future__ import annotations + +from sagemaker.pytorch import PyTorchModel + + +def run_batch_transform( + model_data_s3: str, + input_s3_uri: str, + output_s3_uri: str, + role: str, + instance_type: str = "ml.g5.xlarge", +) -> None: + """Run batch transform on a dataset.""" + model = PyTorchModel( + model_data=model_data_s3, + role=role, + framework_version="2.1.0", + py_version="py310", + entry_point="inference.py", + source_dir="src/inference", + ) + + transformer = model.transformer( + instance_count=1, + instance_type=instance_type, + output_path=output_s3_uri, + strategy="MultiRecord", + max_payload=6, + ) + + transformer.transform( + data=input_s3_uri, + content_type="application/json", + split_type="Line", + ) +``` + +## Local Mode Testing + +### Test Training Locally Before Submitting to SageMaker + +```python +"""Local mode testing for SageMaker training scripts.""" + +from __future__ import annotations + +import pytest +from sagemaker.pytorch import PyTorch + + +@pytest.fixture +def local_estimator() -> PyTorch: + """Create a local mode estimator for testing.""" + return PyTorch( + entry_point="train.py", + source_dir="src/training", + role="arn:aws:iam::000000000000:role/dummy", + instance_type="local", + instance_count=1, + framework_version="2.1.0", + py_version="py310", + hyperparameters={ + "epochs": 1, + "batch-size": 4, + "model-name": "resnet18", + }, + ) + + +def test_training_local(local_estimator: PyTorch, tmp_path: Path) -> None: + """Verify training script runs in local mode.""" + # Create minimal test data + create_test_dataset(tmp_path / "train") + create_test_dataset(tmp_path / "val") + + local_estimator.fit({ + "train": f"file://{tmp_path / 'train'}", + "validation": f"file://{tmp_path / 'val'}", + }) +``` + +## Anti-Patterns + +- **Never hardcode S3 paths** — use SageMaker session defaults and `ParameterString` in pipelines. +- **Never use `instance_type="ml.p3.16xlarge"` for simple models** — right-size instances. Start with `ml.g5.xlarge` and scale up. +- **Never skip local mode testing** — always test with `instance_type="local"` before submitting cloud jobs. +- **Never put credentials in training scripts** — SageMaker injects the IAM role automatically. +- **Never download the full dataset inside the training script** — use SageMaker input channels (`SM_CHANNEL_*`). +- **Never forget to call `wait=False` for long training jobs** — use async job submission and poll status separately. + +## Integration with Other Skills + +- **PyTorch Lightning** — Training scripts use LightningModule inside SageMaker training jobs. +- **Hydra Config** — Hyperparameters serialized and passed to SageMaker as flat key-value pairs. +- **W&B / MLflow** — Experiment tracking inside SageMaker training containers. +- **Docker CV** — Custom training containers when the built-in SageMaker images are insufficient. +- **DVC** — Data versioning with S3 remote storage that SageMaker can access. diff --git a/skills/aws-sagemaker/skill.toml b/skills/aws-sagemaker/skill.toml new file mode 100644 index 0000000..df3c52e --- /dev/null +++ b/skills/aws-sagemaker/skill.toml @@ -0,0 +1,16 @@ +[skill] +name = "aws-sagemaker" +version = "1.0.0" +category = "cloud" +tags = ["aws", "sagemaker", "training", "deployment", "cloud", "mlops"] + +[dependencies] +requires = ["pydantic-strict", "loguru"] +recommends = ["pytorch-lightning", "docker-cv", "wandb", "dvc"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +sagemaker = ">=2.200" +boto3 = ">=1.28" diff --git a/skills/code-quality/SKILL.md b/skills/code-quality/SKILL.md index a2d7f5d..8803316 100644 --- a/skills/code-quality/SKILL.md +++ b/skills/code-quality/SKILL.md @@ -1,3 +1,11 @@ +--- +name: code-quality +description: > + Enforces code quality standards for AI/CV Python projects using Ruff linting, + MyPy strict type checking, and automated formatting. Covers editor integration, + pre-commit hooks, and CI pipeline enforcement. +--- + # Code Quality Skill You are enforcing code quality standards for AI/CV Python projects. Follow these rules exactly. diff --git a/skills/code-quality/skill.toml b/skills/code-quality/skill.toml new file mode 100644 index 0000000..15212ab --- /dev/null +++ b/skills/code-quality/skill.toml @@ -0,0 +1,16 @@ +[skill] +name = "code-quality" +version = "1.0.0" +category = "core" +tags = ["linting", "formatting", "type-checking", "ruff", "mypy"] + +[dependencies] +requires = [] +recommends = ["pre-commit"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +ruff = ">=0.8" +mypy = ">=1.11" diff --git a/skills/docker-cv/SKILL.md b/skills/docker-cv/SKILL.md index dfb0785..696b2ee 100644 --- a/skills/docker-cv/SKILL.md +++ b/skills/docker-cv/SKILL.md @@ -1,3 +1,11 @@ +--- +name: docker-cv +description: > + Build optimized Docker images for computer vision and deep learning workloads. + Covers CUDA support, multi-stage builds, layer caching, security best practices, + and GPU-accelerated container deployment. +--- + # Docker CV Skill Build optimized Docker images for computer vision and deep learning workloads with CUDA support, multi-stage builds, and security best practices. diff --git a/skills/docker-cv/skill.toml b/skills/docker-cv/skill.toml new file mode 100644 index 0000000..095a758 --- /dev/null +++ b/skills/docker-cv/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "docker-cv" +version = "1.0.0" +category = "infra" +tags = ["docker", "containerization", "cuda", "deployment"] + +[dependencies] +requires = [] +recommends = ["pixi", "github-actions"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/dvc/SKILL.md b/skills/dvc/SKILL.md index c193345..13b647c 100644 --- a/skills/dvc/SKILL.md +++ b/skills/dvc/SKILL.md @@ -1,3 +1,11 @@ +--- +name: dvc +description: > + Data Version Control (DVC) for versioning large datasets, models, and ML pipeline + artifacts. Covers DVC initialization, remote storage configuration (S3, GCS, Azure), + pipeline definitions, and integration with Git workflows. +--- + # Data Version Control (DVC) for ML Projects ## Overview diff --git a/skills/dvc/skill.toml b/skills/dvc/skill.toml new file mode 100644 index 0000000..28e3a29 --- /dev/null +++ b/skills/dvc/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "dvc" +version = "1.0.0" +category = "infra" +tags = ["data-versioning", "dvc", "model-versioning", "pipelines"] + +[dependencies] +requires = ["loguru"] +recommends = ["gcp"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +dvc = ">=3.0" diff --git a/skills/fastapi/README.md b/skills/fastapi/README.md new file mode 100644 index 0000000..2289053 --- /dev/null +++ b/skills/fastapi/README.md @@ -0,0 +1,32 @@ +# FastAPI Skill + +The FastAPI Skill provides expert patterns for building ML model serving APIs with FastAPI, covering async endpoints, Pydantic request/response validation, dependency injection for model loading, middleware for request logging and error handling, WebSocket streaming for real-time inference, and production deployment with Uvicorn. + +## Purpose + +When you need to serve ML models over HTTP — whether for batch predictions, real-time video inference, or integration with frontend applications — FastAPI is the standard framework. This skill encodes best practices for structuring FastAPI applications in ML/CV contexts: proper model lifecycle management via lifespan handlers, typed request/response schemas, async inference patterns, and production-ready Dockerfile configurations. + +## When to Use + +- When building REST APIs that serve trained models (ONNX, TensorRT, PyTorch). +- When you need real-time WebSocket streaming for video inference pipelines. +- When constructing microservices that wrap ML inference behind HTTP endpoints. +- When building model serving APIs that need health checks, batch endpoints, and structured error responses. + +## Key Features + +- **Application factory pattern** — configurable app creation with lifespan-managed model loading. +- **Pydantic schemas** — frozen BaseModel for all request and response types with field validators. +- **Dependency injection** — model and preprocessor provided via FastAPI's `Depends` system. +- **Middleware** — request ID injection, logging, timing, and structured exception handling. +- **WebSocket streaming** — real-time frame-by-frame inference over persistent connections. +- **Background tasks** — async result logging and post-processing without blocking responses. +- **Testing** — async test client patterns with httpx `ASGITransport`. + +## Related Skills + +- **[Pydantic Strict](../pydantic-strict/)** — frozen BaseModel patterns used for all API schemas. +- **[ONNX](../onnx/)** / **[TensorRT](../tensorrt/)** — optimized model formats loaded in the lifespan handler. +- **[Docker CV](../docker-cv/)** — production containerization for FastAPI ML services. +- **[Loguru](../loguru/)** — structured logging in middleware and exception handlers. +- **[Testing](../testing/)** — async endpoint testing with httpx. diff --git a/skills/fastapi/SKILL.md b/skills/fastapi/SKILL.md new file mode 100644 index 0000000..e964f8f --- /dev/null +++ b/skills/fastapi/SKILL.md @@ -0,0 +1,609 @@ +--- +name: fastapi +description: > + FastAPI patterns for building ML model serving APIs. Covers async endpoints, + Pydantic request/response models, dependency injection, middleware, CORS, + background tasks, WebSocket streaming, health checks, and structured error handling. +--- + +# FastAPI Skill + +You are building FastAPI applications for serving ML models and CV pipelines. Follow these patterns exactly. + +## Core Philosophy + +FastAPI provides automatic OpenAPI documentation, request validation via Pydantic, and async-first design. Every model serving endpoint in this framework uses FastAPI. Use Pydantic models for all request and response schemas — never accept raw dicts from API consumers. + +## Application Structure + +### Standard Application Factory + +Use an application factory pattern to configure the FastAPI app. This enables testing with different configurations and clean startup/shutdown lifecycle management. + +```python +"""FastAPI application factory for ML model serving.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator +from typing import Any + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from loguru import logger +from pydantic import BaseModel, Field + + +class AppConfig(BaseModel, frozen=True): + """Application configuration.""" + + title: str = "ML Model API" + version: str = "1.0.0" + cors_origins: list[str] = Field(default_factory=lambda: ["*"]) + model_path: str = "models/best.onnx" + max_batch_size: int = 32 + device: str = "cuda:0" + + +class ModelRegistry: + """Holds loaded models for the application lifetime.""" + + def __init__(self) -> None: + self.models: dict[str, Any] = {} + + async def load(self, config: AppConfig) -> None: + logger.info("Loading model from {}", config.model_path) + # Load ONNX, TensorRT, or PyTorch model here + self.models["default"] = await _load_model(config.model_path) + logger.info("Model loaded successfully") + + async def shutdown(self) -> None: + logger.info("Releasing model resources") + self.models.clear() + + +model_registry = ModelRegistry() + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None]: + """Manage model loading on startup and cleanup on shutdown.""" + config = AppConfig() + await model_registry.load(config) + yield + await model_registry.shutdown() + + +def create_app(config: AppConfig | None = None) -> FastAPI: + """Create and configure the FastAPI application.""" + config = config or AppConfig() + + app = FastAPI( + title=config.title, + version=config.version, + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=config.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + from .routes import prediction, health + app.include_router(health.router, tags=["health"]) + app.include_router(prediction.router, prefix="/api/v1", tags=["prediction"]) + + return app +``` + +## Request and Response Models + +### Pydantic Schemas for ML Endpoints + +Define explicit Pydantic models for every endpoint. Never use `dict` or `Any` in API signatures. + +```python +"""Pydantic schemas for prediction endpoints.""" + +from __future__ import annotations + +import base64 + +from pydantic import BaseModel, Field, field_validator + + +class PredictionRequest(BaseModel, frozen=True): + """Single image prediction request.""" + + image_b64: str = Field(..., description="Base64-encoded image bytes") + confidence_threshold: float = Field( + default=0.5, ge=0.0, le=1.0, description="Minimum confidence for detections" + ) + max_detections: int = Field(default=100, ge=1, le=1000) + + @field_validator("image_b64") + @classmethod + def validate_base64(cls, v: str) -> str: + try: + base64.b64decode(v, validate=True) + except Exception as exc: + raise ValueError("Invalid base64 encoding") from exc + return v + + +class Detection(BaseModel, frozen=True): + """Single object detection result.""" + + label: str + confidence: float = Field(ge=0.0, le=1.0) + bbox: list[float] = Field(min_length=4, max_length=4, description="[x1, y1, x2, y2]") + + +class PredictionResponse(BaseModel, frozen=True): + """Prediction response with detections and metadata.""" + + detections: list[Detection] + inference_time_ms: float + model_version: str + + +class BatchPredictionRequest(BaseModel, frozen=True): + """Batch prediction request.""" + + images: list[PredictionRequest] = Field(max_length=32) + + +class BatchPredictionResponse(BaseModel, frozen=True): + """Batch prediction response.""" + + results: list[PredictionResponse] + total_inference_time_ms: float + + +class ErrorResponse(BaseModel, frozen=True): + """Structured error response.""" + + error: str + detail: str | None = None + request_id: str | None = None +``` + +## Endpoint Patterns + +### Prediction Endpoint with Dependency Injection + +```python +"""Prediction routes.""" + +from __future__ import annotations + +import time +from typing import Annotated + +import numpy as np +from fastapi import APIRouter, Depends, HTTPException + +from .schemas import ( + PredictionRequest, + PredictionResponse, + BatchPredictionRequest, + BatchPredictionResponse, + Detection, +) +from .dependencies import get_model, get_preprocessor + +router = APIRouter() + + +@router.post("/predict", response_model=PredictionResponse) +async def predict( + request: PredictionRequest, + model: Annotated[Model, Depends(get_model)], + preprocessor: Annotated[Preprocessor, Depends(get_preprocessor)], +) -> PredictionResponse: + """Run inference on a single image.""" + start = time.perf_counter() + + image = preprocessor.decode_and_preprocess(request.image_b64) + raw_detections = await model.predict(image) + + detections = [ + Detection(label=d.label, confidence=d.confidence, bbox=d.bbox) + for d in raw_detections + if d.confidence >= request.confidence_threshold + ][: request.max_detections] + + elapsed_ms = (time.perf_counter() - start) * 1000 + + return PredictionResponse( + detections=detections, + inference_time_ms=round(elapsed_ms, 2), + model_version=model.version, + ) + + +@router.post("/predict/batch", response_model=BatchPredictionResponse) +async def predict_batch( + request: BatchPredictionRequest, + model: Annotated[Model, Depends(get_model)], + preprocessor: Annotated[Preprocessor, Depends(get_preprocessor)], +) -> BatchPredictionResponse: + """Run inference on a batch of images.""" + start = time.perf_counter() + + results: list[PredictionResponse] = [] + for item in request.images: + result = await predict(item, model, preprocessor) + results.append(result) + + total_ms = (time.perf_counter() - start) * 1000 + + return BatchPredictionResponse( + results=results, + total_inference_time_ms=round(total_ms, 2), + ) +``` + +### Health Check Endpoints + +```python +"""Health check routes.""" + +from __future__ import annotations + +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + + +class HealthResponse(BaseModel, frozen=True): + status: str + model_loaded: bool + version: str + + +@router.get("/health", response_model=HealthResponse) +async def health_check() -> HealthResponse: + """Liveness and readiness probe.""" + from .app import model_registry + + return HealthResponse( + status="healthy", + model_loaded=bool(model_registry.models), + version="1.0.0", + ) + + +@router.get("/ready") +async def readiness() -> dict[str, bool]: + """Kubernetes readiness probe.""" + from .app import model_registry + + if not model_registry.models: + raise HTTPException(status_code=503, detail="Model not loaded") + return {"ready": True} +``` + +## Dependency Injection + +### Model and Preprocessor Dependencies + +```python +"""FastAPI dependencies for ML serving.""" + +from __future__ import annotations + +from functools import lru_cache + +from fastapi import Depends + + +@lru_cache(maxsize=1) +def get_app_config() -> AppConfig: + """Load application configuration once.""" + return AppConfig() + + +async def get_model( + config: AppConfig = Depends(get_app_config), +) -> Model: + """Provide the loaded model instance.""" + from .app import model_registry + + model = model_registry.models.get("default") + if model is None: + raise HTTPException(status_code=503, detail="Model not available") + return model + + +async def get_preprocessor( + config: AppConfig = Depends(get_app_config), +) -> Preprocessor: + """Provide the image preprocessor.""" + return Preprocessor(target_size=(640, 640), device=config.device) +``` + +## Middleware and Error Handling + +### Request ID and Logging Middleware + +```python +"""Custom middleware for ML API.""" + +from __future__ import annotations + +import time +import uuid + +from fastapi import FastAPI, Request, Response +from loguru import logger +from starlette.middleware.base import BaseHTTPMiddleware + + +class RequestLoggingMiddleware(BaseHTTPMiddleware): + """Log all requests with timing and request ID.""" + + async def dispatch(self, request: Request, call_next) -> Response: + request_id = str(uuid.uuid4())[:8] + request.state.request_id = request_id + + start = time.perf_counter() + response = await call_next(request) + elapsed = (time.perf_counter() - start) * 1000 + + logger.info( + "{method} {path} → {status} ({elapsed:.1f}ms) [{rid}]", + method=request.method, + path=request.url.path, + status=response.status_code, + elapsed=elapsed, + rid=request_id, + ) + + response.headers["X-Request-ID"] = request_id + return response +``` + +### Structured Exception Handlers + +```python +"""Exception handlers for consistent error responses.""" + +from __future__ import annotations + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from loguru import logger + + +async def model_error_handler(request: Request, exc: ModelInferenceError) -> JSONResponse: + """Handle model inference failures.""" + logger.error("Inference error: {}", exc) + return JSONResponse( + status_code=500, + content={ + "error": "inference_error", + "detail": str(exc), + "request_id": getattr(request.state, "request_id", None), + }, + ) + + +async def validation_error_handler(request: Request, exc: ValueError) -> JSONResponse: + """Handle input validation errors with clear messages.""" + return JSONResponse( + status_code=422, + content={"error": "validation_error", "detail": str(exc)}, + ) + + +def register_exception_handlers(app: FastAPI) -> None: + """Register all exception handlers on the app.""" + app.add_exception_handler(ModelInferenceError, model_error_handler) +``` + +## WebSocket Streaming + +### Real-Time Video Inference + +```python +"""WebSocket endpoint for streaming inference.""" + +from __future__ import annotations + +import base64 + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from loguru import logger + +router = APIRouter() + + +@router.websocket("/ws/stream") +async def stream_inference(websocket: WebSocket) -> None: + """Stream video frames and return detections in real time.""" + await websocket.accept() + logger.info("WebSocket client connected") + + try: + while True: + data = await websocket.receive_json() + frame_b64 = data.get("frame") + if not frame_b64: + await websocket.send_json({"error": "Missing 'frame' field"}) + continue + + frame_bytes = base64.b64decode(frame_b64) + detections = await run_inference(frame_bytes) + + await websocket.send_json({ + "detections": [d.model_dump() for d in detections], + "frame_id": data.get("frame_id"), + }) + except WebSocketDisconnect: + logger.info("WebSocket client disconnected") +``` + +## Background Tasks + +### Async Post-Processing + +```python +"""Background task patterns for FastAPI ML APIs.""" + +from __future__ import annotations + +from fastapi import APIRouter, BackgroundTasks +from loguru import logger + +router = APIRouter() + + +async def save_prediction_to_db( + request_id: str, + detections: list[Detection], +) -> None: + """Save prediction results asynchronously after response.""" + logger.info("Saving {} detections for request {}", len(detections), request_id) + await db.predictions.insert_one({ + "request_id": request_id, + "detections": [d.model_dump() for d in detections], + }) + + +@router.post("/predict") +async def predict_with_logging( + request: PredictionRequest, + background_tasks: BackgroundTasks, +) -> PredictionResponse: + """Predict and log results in background.""" + result = await run_prediction(request) + + background_tasks.add_task( + save_prediction_to_db, + request_id=request.state.request_id, + detections=result.detections, + ) + + return result +``` + +## Testing FastAPI Applications + +### Async Test Client + +```python +"""Tests for prediction API.""" + +from __future__ import annotations + +import base64 + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import create_app + + +@pytest.fixture +async def client() -> AsyncClient: + """Create async test client.""" + app = create_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest.mark.anyio +async def test_health_check(client: AsyncClient) -> None: + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + + +@pytest.mark.anyio +async def test_predict_returns_detections(client: AsyncClient) -> None: + image_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + request_body = { + "image_b64": base64.b64encode(image_bytes).decode(), + "confidence_threshold": 0.5, + } + response = await client.post("/api/v1/predict", json=request_body) + assert response.status_code == 200 + data = response.json() + assert "detections" in data + assert "inference_time_ms" in data + + +@pytest.mark.anyio +async def test_predict_rejects_invalid_base64(client: AsyncClient) -> None: + response = await client.post( + "/api/v1/predict", + json={"image_b64": "not-valid-base64!!!"}, + ) + assert response.status_code == 422 +``` + +## Docker Deployment + +### Production Dockerfile for FastAPI ML API + +```dockerfile +FROM python:3.11-slim AS base + +WORKDIR /app +RUN pip install --no-cache-dir uv + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY src/ ./src/ + +EXPOSE 8000 + +CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] +``` + +### Uvicorn Configuration + +```python +"""Uvicorn runner with production settings.""" + +import uvicorn + +if __name__ == "__main__": + uvicorn.run( + "app.main:create_app", + factory=True, + host="0.0.0.0", + port=8000, + workers=4, + log_level="info", + access_log=True, + limit_concurrency=100, + timeout_keep_alive=30, + ) +``` + +## Anti-Patterns + +- **Never use `dict` for request/response models** — always define Pydantic schemas with explicit fields and validators. +- **Never load models inside endpoint functions** — use the lifespan context manager for startup/shutdown lifecycle. +- **Never block the event loop with synchronous inference** — use `await` with async model wrappers or `run_in_executor` for CPU-bound operations. +- **Never hardcode CORS origins in production** — load from configuration. +- **Never return raw numpy arrays or tensors** — serialize to lists or base64 in the response model. +- **Never skip input validation** — use Pydantic field validators for base64, image dimensions, and parameter bounds. + +## Integration with Other Skills + +- **Pydantic Strict** — All request/response models follow frozen BaseModel patterns. +- **Docker CV** — Production Dockerfiles with multi-stage builds for FastAPI + model serving. +- **ONNX / TensorRT** — Load optimized models in the lifespan handler. +- **Loguru** — Structured logging in middleware and exception handlers. +- **Testing** — Async test client with httpx for full endpoint coverage. diff --git a/skills/fastapi/skill.toml b/skills/fastapi/skill.toml new file mode 100644 index 0000000..1f6fc6f --- /dev/null +++ b/skills/fastapi/skill.toml @@ -0,0 +1,17 @@ +[skill] +name = "fastapi" +version = "1.0.0" +category = "infra" +tags = ["api", "serving", "fastapi", "rest", "async"] + +[dependencies] +requires = ["pydantic-strict", "loguru"] +recommends = ["docker-cv", "onnx", "testing"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +fastapi = ">=0.100" +uvicorn = ">=0.20" +httpx = ">=0.24" diff --git a/skills/gcp/SKILL.md b/skills/gcp/SKILL.md index 2e4c97e..811ec5d 100644 --- a/skills/gcp/SKILL.md +++ b/skills/gcp/SKILL.md @@ -1,3 +1,11 @@ +--- +name: gcp +description: > + Google Cloud Platform services for CV/ML projects. Covers Artifact Registry for + Docker images, Cloud Storage for datasets, Vertex AI for training jobs, and + gcloud CLI patterns for infrastructure management. +--- + # GCP Skill Google Cloud Platform services for CV/ML projects: Artifact Registry, Cloud Storage, Vertex AI training, and Docker image management. diff --git a/skills/gcp/skill.toml b/skills/gcp/skill.toml new file mode 100644 index 0000000..f8178e0 --- /dev/null +++ b/skills/gcp/skill.toml @@ -0,0 +1,16 @@ +[skill] +name = "gcp" +version = "1.0.0" +category = "infra" +tags = ["cloud", "gcp", "vertex-ai", "gcs", "deployment"] + +[dependencies] +requires = ["docker-cv"] +recommends = ["github-actions"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +google-cloud-storage = ">=2.0" +google-cloud-aiplatform = ">=1.30" diff --git a/skills/github-actions/SKILL.md b/skills/github-actions/SKILL.md index a44b2c3..5b0771c 100644 --- a/skills/github-actions/SKILL.md +++ b/skills/github-actions/SKILL.md @@ -1,3 +1,11 @@ +--- +name: github-actions +description: > + CI/CD workflow patterns for ML/CV projects using GitHub Actions. Covers tiered + pipelines (lint, test, build, deploy), GPU runner support, dependency caching, + matrix builds, and AI agent integration. +--- + # GitHub Actions Skill CI/CD workflow patterns for ML/CV projects with tiered pipelines, GPU runner support, caching, and AI agent integration. diff --git a/skills/github-actions/skill.toml b/skills/github-actions/skill.toml new file mode 100644 index 0000000..1e3210f --- /dev/null +++ b/skills/github-actions/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "github-actions" +version = "1.0.0" +category = "infra" +tags = ["ci-cd", "automation", "github", "workflows"] + +[dependencies] +requires = [] +recommends = ["testing", "code-quality", "docker-cv"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/github-repo-setup/SKILL.md b/skills/github-repo-setup/SKILL.md index c44787c..e698775 100644 --- a/skills/github-repo-setup/SKILL.md +++ b/skills/github-repo-setup/SKILL.md @@ -1,3 +1,11 @@ +--- +name: github-repo-setup +description: > + Best practices for initializing and configuring GitHub repositories for CV/ML projects. + Covers repository creation, branch protection rules, PR and issue templates, + CODEOWNERS, merge strategies, and gh CLI automation. +--- + # GitHub Repository Setup Skill Best practices for initializing and configuring GitHub repositories for CV/ML projects. Covers repository creation, branch protection, PR templates, issue templates, CODEOWNERS, merge strategy, and automation via the `gh` CLI. diff --git a/skills/github-repo-setup/skill.toml b/skills/github-repo-setup/skill.toml new file mode 100644 index 0000000..33140ea --- /dev/null +++ b/skills/github-repo-setup/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "github-repo-setup" +version = "1.0.0" +category = "infra" +tags = ["repository", "github", "setup", "initialization"] + +[dependencies] +requires = [] +recommends = ["github-actions", "pre-commit"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/gradio/README.md b/skills/gradio/README.md new file mode 100644 index 0000000..7a9e239 --- /dev/null +++ b/skills/gradio/README.md @@ -0,0 +1,38 @@ +# Gradio Skill + +The Gradio Skill provides expert patterns for building interactive ML demos and model serving UIs with Gradio 4.x. It covers `gr.Interface` for simple demos, `gr.Blocks` for complex multi-tab layouts, multi-modal inputs and outputs (image, video, text), ONNX and PyTorch model integration, custom flagging for feedback collection, and deployment to Hugging Face Spaces and Docker. + +## Purpose + +When you need to quickly build an interactive demo for a trained model -- whether for stakeholder presentations, user testing, dataset labeling feedback, or public deployment -- Gradio is the standard tool. This skill encodes best practices for structuring Gradio applications in ML/CV contexts: proper model lifecycle management at startup, typed Pydantic configurations, reusable component patterns, and production deployment strategies including Hugging Face Spaces and FastAPI mounting. + +## When to Use + +- When building interactive demos for image classification, object detection, or segmentation models. +- When you need a quick web UI for model prototyping and stakeholder presentations. +- When collecting user feedback on model predictions via flagging for active learning. +- When deploying public-facing demos to Hugging Face Spaces. +- When creating side-by-side model comparison tools for evaluation. +- When serving real-time webcam inference through a browser-based interface. +- When mounting an ML demo inside an existing FastAPI application. + +## Key Features + +- **gr.Interface** -- single-function demos with automatic input/output type inference and example caching. +- **gr.Blocks** -- complex layouts with tabs, rows, columns, and conditional visibility for multi-step workflows. +- **Multi-modal I/O** -- image, video, text, audio, JSON, and gallery components for diverse ML tasks. +- **Model serving** -- ONNX and PyTorch model loading at startup with Pydantic-configured inference pipelines. +- **gr.load** -- instant demos from Hugging Face Hub models without writing inference code. +- **Custom flagging** -- structured feedback collection for active learning and dataset improvement. +- **Deployment** -- Hugging Face Spaces, Docker, share links, authentication, and FastAPI mounting. +- **Reusable components** -- model selectors, preprocessing controls, and comparison layouts. + +## Related Skills + +- **[FastAPI](../fastapi/)** -- mount Gradio demos inside FastAPI apps for combined API + demo serving. +- **[Hugging Face](../huggingface/)** -- load pretrained models from the Hub and deploy to Hugging Face Spaces. +- **[PyTorch Lightning](../pytorch-lightning/)** -- load Lightning checkpoints and serve trained models through Gradio. +- **[Testing](../testing/)** -- test Gradio components with the Gradio test client and pytest fixtures. +- **[ONNX](../onnx/)** -- serve optimized ONNX models for low-latency Gradio inference. +- **[Pydantic Strict](../pydantic-strict/)** -- frozen BaseModel patterns for all Gradio configuration objects. +- **[Loguru](../loguru/)** -- structured logging in model loading, prediction callbacks, and flagging. diff --git a/skills/gradio/SKILL.md b/skills/gradio/SKILL.md new file mode 100644 index 0000000..9c2328f --- /dev/null +++ b/skills/gradio/SKILL.md @@ -0,0 +1,662 @@ +--- +name: gradio +description: > + Gradio patterns for building interactive ML demos and model serving UIs. + Covers gr.Interface, gr.Blocks layouts, image/video/text inputs and outputs, + model serving with gr.load, custom components, flagging and feedback collection, + deployment to Hugging Face Spaces, and integration with PyTorch/ONNX models. +--- + +# Gradio Skill + +You are building Gradio applications for interactive ML demos and model serving UIs. Follow these patterns exactly. + +## Core Philosophy + +Gradio provides the fastest path from a trained model to an interactive web demo. Every demo in this framework uses Gradio 4.x with typed interfaces, Pydantic-validated configurations, and Loguru-based logging. Use `gr.Blocks` for complex layouts and `gr.Interface` for simple single-function demos. Never expose raw model internals to the UI layer. + +## Interface Creation + +### Simple Interface with gr.Interface + +Use `gr.Interface` when wrapping a single prediction function with clearly defined inputs and outputs. This is the fastest way to prototype a demo. + +```python +"""Simple image classification demo with gr.Interface.""" + +from __future__ import annotations + +import gradio as gr +import numpy as np +from loguru import logger +from PIL import Image +from pydantic import BaseModel, Field + + +class ClassificationConfig(BaseModel, frozen=True): + """Configuration for classification demo.""" + + model_path: str = "models/classifier.onnx" + labels_path: str = "models/labels.txt" + top_k: int = Field(default=5, ge=1, le=20) + image_size: tuple[int, int] = (224, 224) + + +def classify_image( + image: Image.Image, + config: ClassificationConfig = ClassificationConfig(), +) -> dict[str, float]: + """Run classification on a single image and return top-k predictions.""" + logger.info("Received image of size {}", image.size) + preprocessed = preprocess(image, config.image_size) + predictions = run_model(preprocessed, config.model_path) + labels = load_labels(config.labels_path) + + top_indices = np.argsort(predictions)[-config.top_k :][::-1] + results = {labels[i]: float(predictions[i]) for i in top_indices} + logger.info("Top prediction: {} ({:.3f})", list(results.keys())[0], list(results.values())[0]) + return results + + +demo = gr.Interface( + fn=classify_image, + inputs=gr.Image(type="pil", label="Upload Image"), + outputs=gr.Label(num_top_classes=5, label="Predictions"), + title="Image Classifier", + description="Upload an image to classify it using the trained model.", + examples=[["examples/cat.jpg"], ["examples/dog.jpg"]], + cache_examples=True, +) +``` + +### Complex Layouts with gr.Blocks + +Use `gr.Blocks` for multi-step workflows, side-by-side comparisons, tabs, and conditional visibility. This is the standard for production demos. + +```python +"""Object detection demo with gr.Blocks layout.""" + +from __future__ import annotations + +from pathlib import Path + +import gradio as gr +import numpy as np +from loguru import logger +from PIL import Image +from pydantic import BaseModel, Field + + +class DetectionConfig(BaseModel, frozen=True): + """Detection demo configuration.""" + + model_path: str = "models/detector.onnx" + confidence_threshold: float = Field(default=0.5, ge=0.0, le=1.0) + nms_threshold: float = Field(default=0.45, ge=0.0, le=1.0) + max_detections: int = Field(default=100, ge=1, le=500) + device: str = "cpu" + + +def build_detection_demo(config: DetectionConfig | None = None) -> gr.Blocks: + """Build the detection demo application.""" + config = config or DetectionConfig() + logger.info("Building detection demo with model: {}", config.model_path) + + with gr.Blocks(title="Object Detection", theme=gr.themes.Soft()) as demo: + gr.Markdown("# Object Detection Demo") + gr.Markdown("Upload an image or use the webcam to detect objects in real time.") + + with gr.Tab("Image Upload"): + with gr.Row(): + with gr.Column(scale=1): + input_image = gr.Image(type="pil", label="Input Image") + confidence_slider = gr.Slider( + minimum=0.0, + maximum=1.0, + value=config.confidence_threshold, + step=0.05, + label="Confidence Threshold", + ) + detect_btn = gr.Button("Detect Objects", variant="primary") + + with gr.Column(scale=1): + output_image = gr.Image(type="pil", label="Detections") + output_json = gr.JSON(label="Detection Results") + + detect_btn.click( + fn=run_detection, + inputs=[input_image, confidence_slider], + outputs=[output_image, output_json], + ) + + with gr.Tab("Webcam"): + webcam_input = gr.Image(sources=["webcam"], type="pil", label="Webcam Feed") + webcam_output = gr.Image(type="pil", label="Live Detections") + webcam_input.stream( + fn=run_detection_realtime, + inputs=[webcam_input], + outputs=[webcam_output], + ) + + with gr.Tab("Batch Processing"): + batch_input = gr.File( + file_count="multiple", + file_types=["image"], + label="Upload Multiple Images", + ) + batch_output = gr.Gallery(label="Results", columns=3) + batch_btn = gr.Button("Process Batch", variant="primary") + batch_btn.click( + fn=run_batch_detection, + inputs=[batch_input, confidence_slider], + outputs=[batch_output], + ) + + return demo +``` + +## Image, Video, and Text Inputs and Outputs + +### Multi-Modal Input/Output Patterns + +```python +"""Multi-modal Gradio interface patterns.""" + +from __future__ import annotations + +import gradio as gr +from loguru import logger + + +def build_multimodal_demo() -> gr.Blocks: + """Build a demo with image, video, and text I/O.""" + with gr.Blocks() as demo: + gr.Markdown("# Multi-Modal Analysis") + + with gr.Tab("Image Captioning"): + img_input = gr.Image(type="pil", label="Upload Image") + caption_output = gr.Textbox(label="Generated Caption", lines=3) + img_input.change(fn=generate_caption, inputs=[img_input], outputs=[caption_output]) + + with gr.Tab("Video Analysis"): + video_input = gr.Video(label="Upload Video") + video_output = gr.Video(label="Annotated Video") + frame_gallery = gr.Gallery(label="Key Frames", columns=4) + analysis_json = gr.JSON(label="Analysis Results") + + analyze_btn = gr.Button("Analyze Video", variant="primary") + analyze_btn.click( + fn=analyze_video, + inputs=[video_input], + outputs=[video_output, frame_gallery, analysis_json], + ) + + with gr.Tab("Visual Question Answering"): + with gr.Row(): + vqa_image = gr.Image(type="pil", label="Image") + with gr.Column(): + vqa_question = gr.Textbox(label="Question", placeholder="What is in this image?") + vqa_answer = gr.Textbox(label="Answer", interactive=False) + vqa_btn = gr.Button("Ask", variant="primary") + + vqa_btn.click( + fn=answer_question, + inputs=[vqa_image, vqa_question], + outputs=[vqa_answer], + ) + + return demo +``` + +## Model Serving with gr.load + +### Loading Models from Hugging Face Hub + +Use `gr.load` to quickly create a demo from a Hugging Face model without writing inference code. + +```python +"""Serve Hugging Face models directly with gr.load.""" + +from __future__ import annotations + +import gradio as gr +from loguru import logger + +# Load a model directly from the Hugging Face Hub +logger.info("Loading model from Hugging Face Hub") +demo = gr.load( + name="facebook/detr-resnet-50", + src="models", + title="DETR Object Detection", + description="Detect objects using DETR (DEtection TRansformer).", +) + +# Load a Hugging Face Space as a component +image_gen = gr.load( + name="stabilityai/stable-diffusion-2", + src="models", +) +``` + +### Serving Custom PyTorch and ONNX Models + +```python +"""Serve a custom ONNX model through Gradio.""" + +from __future__ import annotations + +from pathlib import Path + +import gradio as gr +import numpy as np +import onnxruntime as ort +from loguru import logger +from PIL import Image +from pydantic import BaseModel, Field + + +class ONNXModelConfig(BaseModel, frozen=True): + """ONNX model serving configuration.""" + + model_path: Path = Path("models/model.onnx") + input_size: tuple[int, int] = (640, 640) + providers: list[str] = Field( + default_factory=lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"] + ) + + +class ModelServer: + """Wraps an ONNX model for Gradio serving.""" + + def __init__(self, config: ONNXModelConfig) -> None: + self.config = config + logger.info("Loading ONNX model from {}", config.model_path) + self.session = ort.InferenceSession( + str(config.model_path), + providers=config.providers, + ) + self.input_name = self.session.get_inputs()[0].name + logger.info("Model loaded successfully with providers: {}", config.providers) + + def predict(self, image: Image.Image) -> tuple[Image.Image, dict]: + """Run inference and return annotated image and results dict.""" + logger.debug("Preprocessing image of size {}", image.size) + input_array = self._preprocess(image) + + outputs = self.session.run(None, {self.input_name: input_array}) + detections = self._postprocess(outputs, image.size) + + annotated = self._draw_detections(image, detections) + results = {"num_detections": len(detections), "detections": detections} + logger.info("Found {} detections", len(detections)) + return annotated, results + + def _preprocess(self, image: Image.Image) -> np.ndarray: + """Resize and normalize input image.""" + resized = image.resize(self.config.input_size) + array = np.array(resized, dtype=np.float32) / 255.0 + return np.transpose(array, (2, 0, 1))[np.newaxis, ...] + + def _postprocess(self, outputs: list, original_size: tuple[int, int]) -> list[dict]: + """Convert raw model outputs to detection dicts.""" + # Implementation depends on model architecture + ... + + def _draw_detections(self, image: Image.Image, detections: list[dict]) -> Image.Image: + """Draw bounding boxes on the image.""" + # Implementation uses PIL.ImageDraw + ... + + +def create_onnx_demo(config: ONNXModelConfig | None = None) -> gr.Blocks: + """Create a Gradio demo serving an ONNX model.""" + config = config or ONNXModelConfig() + server = ModelServer(config) + + with gr.Blocks(title="ONNX Model Demo") as demo: + gr.Markdown("# Custom ONNX Model Demo") + with gr.Row(): + input_img = gr.Image(type="pil", label="Input") + output_img = gr.Image(type="pil", label="Output") + results_json = gr.JSON(label="Results") + run_btn = gr.Button("Run Inference", variant="primary") + + run_btn.click( + fn=server.predict, + inputs=[input_img], + outputs=[output_img, results_json], + ) + + return demo +``` + +## Custom Components and Layouts + +### Reusable Component Patterns + +```python +"""Reusable Gradio component patterns.""" + +from __future__ import annotations + +import gradio as gr +from loguru import logger + + +def create_model_selector( + models: dict[str, str], + default: str | None = None, +) -> gr.Dropdown: + """Create a model selection dropdown with display names.""" + choices = list(models.keys()) + return gr.Dropdown( + choices=choices, + value=default or choices[0], + label="Select Model", + info="Choose a model for inference", + ) + + +def create_preprocessing_controls() -> tuple[gr.Slider, gr.Slider, gr.Checkbox]: + """Create standard image preprocessing controls.""" + brightness = gr.Slider(-1.0, 1.0, value=0.0, step=0.1, label="Brightness") + contrast = gr.Slider(0.0, 3.0, value=1.0, step=0.1, label="Contrast") + grayscale = gr.Checkbox(value=False, label="Convert to Grayscale") + return brightness, contrast, grayscale + + +def build_comparison_layout() -> gr.Blocks: + """Build a side-by-side model comparison layout.""" + with gr.Blocks() as demo: + gr.Markdown("# Model Comparison") + input_image = gr.Image(type="pil", label="Input Image") + + with gr.Row(): + with gr.Column(): + gr.Markdown("### Model A") + model_a_selector = create_model_selector( + {"YOLOv8-n": "yolov8n", "YOLOv8-s": "yolov8s"}, + default="YOLOv8-n", + ) + output_a = gr.Image(type="pil", label="Model A Output") + metrics_a = gr.JSON(label="Model A Metrics") + + with gr.Column(): + gr.Markdown("### Model B") + model_b_selector = create_model_selector( + {"YOLOv8-m": "yolov8m", "YOLOv8-l": "yolov8l"}, + default="YOLOv8-m", + ) + output_b = gr.Image(type="pil", label="Model B Output") + metrics_b = gr.JSON(label="Model B Metrics") + + compare_btn = gr.Button("Compare Models", variant="primary") + compare_btn.click( + fn=compare_models, + inputs=[input_image, model_a_selector, model_b_selector], + outputs=[output_a, metrics_a, output_b, metrics_b], + ) + + return demo +``` + +## Flagging and Feedback Collection + +### Custom Flagging Callback + +Use flagging to collect user feedback on model predictions for dataset improvement and active learning. + +```python +"""Custom flagging callback for ML feedback collection.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +import gradio as gr +from loguru import logger +from pydantic import BaseModel, Field + + +class FlaggedSample(BaseModel, frozen=True): + """A flagged sample with metadata.""" + + timestamp: str + flag_reason: str + input_hash: str + prediction: str + user_correction: str | None = None + + +class MLFlaggingCallback(gr.FlaggingCallback): + """Custom flagging callback that saves structured feedback.""" + + def __init__(self, output_dir: str = "flagged_data") -> None: + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + logger.info("Flagging callback initialized, saving to {}", self.output_dir) + + def setup(self, components: list, flagging_dir: str | Path) -> None: + """Set up the flagging directory.""" + self.flagging_dir = Path(flagging_dir) + self.flagging_dir.mkdir(parents=True, exist_ok=True) + + def flag( + self, + flag_data: list, + flag_option: str = "incorrect", + username: str | None = None, + ) -> int: + """Save a flagged sample with structured metadata.""" + sample = FlaggedSample( + timestamp=datetime.now(tz=timezone.utc).isoformat(), + flag_reason=flag_option, + input_hash=str(hash(str(flag_data[0]))), + prediction=str(flag_data[1]) if len(flag_data) > 1 else "", + user_correction=str(flag_data[2]) if len(flag_data) > 2 else None, + ) + output_path = self.output_dir / f"flag_{sample.timestamp}.json" + output_path.write_text(sample.model_dump_json(indent=2)) + logger.info("Flagged sample saved: {} (reason: {})", output_path, flag_option) + return 1 + + +# Usage in a demo +demo = gr.Interface( + fn=classify_image, + inputs=gr.Image(type="pil"), + outputs=gr.Label(), + flagging_callback=MLFlaggingCallback(output_dir="flagged_data"), + flagging_options=["incorrect", "low_confidence", "interesting"], +) +``` + +## Deployment + +### Sharing and Hugging Face Spaces + +```python +"""Deployment patterns for Gradio demos.""" + +from __future__ import annotations + +import gradio as gr +from loguru import logger + + +def launch_demo(demo: gr.Blocks, share: bool = False) -> None: + """Launch the Gradio demo with production settings.""" + logger.info("Launching Gradio demo (share={})", share) + demo.launch( + server_name="0.0.0.0", + server_port=7860, + share=share, + show_error=True, + max_threads=10, + auth=None, # Set ("user", "pass") for basic auth + ) + + +def launch_with_auth(demo: gr.Blocks) -> None: + """Launch with authentication for internal deployments.""" + logger.info("Launching Gradio demo with authentication") + demo.launch( + server_name="0.0.0.0", + server_port=7860, + auth=[("admin", "secure_password")], + auth_message="Enter credentials to access the demo.", + ) + + +def mount_on_fastapi(demo: gr.Blocks) -> None: + """Mount Gradio inside a FastAPI application.""" + from fastapi import FastAPI + + app = FastAPI(title="ML Demo API") + + @app.get("/api/health") + async def health() -> dict[str, str]: + return {"status": "healthy"} + + app = gr.mount_gradio_app(app, demo, path="/demo") + logger.info("Gradio demo mounted at /demo") +``` + +### Hugging Face Spaces Dockerfile + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +RUN pip install --no-cache-dir uv +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY src/ ./src/ +COPY models/ ./models/ + +EXPOSE 7860 + +CMD ["uv", "run", "python", "-m", "src.app", "--server-name", "0.0.0.0", "--server-port", "7860"] +``` + +### Hugging Face Spaces app.py entry point + +```python +"""Hugging Face Spaces entry point.""" + +from __future__ import annotations + +import gradio as gr +from loguru import logger + +from src.demo import build_detection_demo +from src.config import DetectionConfig + + +logger.info("Starting Hugging Face Spaces demo") +config = DetectionConfig() +demo = build_detection_demo(config) + +if __name__ == "__main__": + demo.launch(server_name="0.0.0.0", server_port=7860) +``` + +## Integration with PyTorch Models + +### PyTorch Lightning Model in Gradio + +```python +"""Serve a PyTorch Lightning model through Gradio.""" + +from __future__ import annotations + +from pathlib import Path + +import gradio as gr +import torch +from loguru import logger +from PIL import Image +from pydantic import BaseModel, Field +from torchvision import transforms + + +class TorchModelConfig(BaseModel, frozen=True): + """PyTorch model serving configuration.""" + + checkpoint_path: Path = Path("checkpoints/best.ckpt") + device: str = "cuda" if torch.cuda.is_available() else "cpu" + input_size: tuple[int, int] = (224, 224) + class_names: list[str] = Field(default_factory=list) + + +def load_pytorch_model(config: TorchModelConfig) -> torch.nn.Module: + """Load a PyTorch model from checkpoint.""" + logger.info("Loading PyTorch model from {}", config.checkpoint_path) + model = torch.load(config.checkpoint_path, map_location=config.device) + model.eval() + logger.info("Model loaded on device: {}", config.device) + return model + + +def create_pytorch_demo(config: TorchModelConfig | None = None) -> gr.Blocks: + """Create a Gradio demo for a PyTorch model.""" + config = config or TorchModelConfig() + model = load_pytorch_model(config) + + transform = transforms.Compose([ + transforms.Resize(config.input_size), + transforms.ToTensor(), + transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), + ]) + + @torch.inference_mode() + def predict(image: Image.Image) -> dict[str, float]: + """Run inference on a single image.""" + tensor = transform(image).unsqueeze(0).to(config.device) + output = model(tensor) + probabilities = torch.nn.functional.softmax(output[0], dim=0) + + top5_prob, top5_idx = torch.topk(probabilities, 5) + results = {} + for prob, idx in zip(top5_prob, top5_idx): + label = config.class_names[idx] if config.class_names else f"class_{idx}" + results[label] = float(prob) + + logger.info("Top prediction: {} ({:.3f})", list(results.keys())[0], list(results.values())[0]) + return results + + with gr.Blocks(title="PyTorch Model Demo") as demo: + gr.Markdown("# PyTorch Model Demo") + with gr.Row(): + input_img = gr.Image(type="pil", label="Upload Image") + output_label = gr.Label(num_top_classes=5, label="Predictions") + predict_btn = gr.Button("Classify", variant="primary") + predict_btn.click(fn=predict, inputs=[input_img], outputs=[output_label]) + + return demo +``` + +## Anti-Patterns + +- **Never load models inside Gradio callback functions** -- load models once at startup and reference them via closure or a server class. Reloading on every request causes massive latency. +- **Never use `gr.Interface` for complex multi-step workflows** -- use `gr.Blocks` with explicit layout control for anything beyond a single input-output function. +- **Never expose raw tensors or numpy arrays to Gradio outputs** -- always convert to PIL Images, JSON-serializable dicts, or strings before returning. +- **Never hardcode file paths in Gradio demos** -- use Pydantic config models to make paths configurable for different environments. +- **Never skip input validation** -- validate image sizes, file types, and parameter ranges before feeding data to the model. +- **Never use `share=True` in production deployments** -- use proper hosting (Hugging Face Spaces, Docker, or a reverse proxy) instead of Gradio's temporary share links. +- **Never block the main thread with long-running inference** -- use `gr.Progress` for progress tracking and consider async patterns for heavy workloads. +- **Never ignore logging** -- use Loguru for structured logging in all prediction functions, model loading, and error handling. + +## Integration with Other Skills + +- **Pydantic Strict** -- All configuration models follow frozen BaseModel patterns with validators. +- **Loguru** -- Structured logging in model loading, prediction functions, and flagging callbacks. +- **Hugging Face** -- Load pretrained models from the Hub and deploy demos to Hugging Face Spaces. +- **FastAPI** -- Mount Gradio demos inside FastAPI applications for combined API + demo serving. +- **ONNX** -- Serve optimized ONNX models through Gradio for low-latency inference. +- **PyTorch Lightning** -- Load Lightning checkpoints and serve trained models through Gradio. +- **Testing** -- Test Gradio components with the Gradio test client and pytest. diff --git a/skills/gradio/skill.toml b/skills/gradio/skill.toml new file mode 100644 index 0000000..8889988 --- /dev/null +++ b/skills/gradio/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "gradio" +version = "1.0.0" +category = "infra" +tags = ["demo", "ui", "gradio", "prototyping", "serving"] + +[dependencies] +requires = ["pydantic-strict", "loguru"] +recommends = ["huggingface", "fastapi"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +gradio = ">=4.0" diff --git a/skills/huggingface/README.md b/skills/huggingface/README.md new file mode 100644 index 0000000..0c64d9b --- /dev/null +++ b/skills/huggingface/README.md @@ -0,0 +1,34 @@ +# Hugging Face Skill + +The Hugging Face Skill provides expert patterns for working with the Hugging Face ecosystem — Transformers, Datasets, Tokenizers, PEFT, and the Model Hub. It covers pretrained model loading with AutoModel classes, dataset preprocessing, fine-tuning with the Trainer API, parameter-efficient fine-tuning with LoRA, pipeline inference, model publishing, and quantization for optimized deployment. + +## Purpose + +When you need to use pretrained models for vision or NLP tasks — whether for classification, detection, zero-shot learning, or fine-tuning on custom data — the Hugging Face ecosystem is the standard toolkit. This skill encodes best practices for loading models safely with version pinning, preprocessing datasets efficiently with batched mapping, fine-tuning with proper evaluation metrics and early stopping, and publishing models with complete model cards. + +## When to Use + +- When loading pretrained vision models (ResNet, ViT, DETR, CLIP) or text models (BERT, RoBERTa). +- When fine-tuning models on custom datasets with the Trainer API. +- When using PEFT/LoRA for parameter-efficient adaptation with limited data. +- When building quick inference pipelines for classification, detection, or zero-shot tasks. +- When publishing models and datasets to the Hugging Face Hub. +- When quantizing models to 4-bit or 8-bit for faster inference. + +## Key Features + +- **AutoModel loading** — type-safe model loading with revision pinning and device mapping. +- **Datasets library** — efficient loading, preprocessing, and batched tokenization. +- **Trainer API** — standardized fine-tuning with evaluation, early stopping, and checkpoint management. +- **PEFT / LoRA** — parameter-efficient fine-tuning that trains <1% of parameters. +- **Pipeline API** — quick inference for image classification, object detection, and zero-shot tasks. +- **Hub publishing** — model upload with model cards, metrics, and licensing. +- **Quantization** — 4-bit and 8-bit model loading for memory-efficient inference. + +## Related Skills + +- **[PyTorch Lightning](../pytorch-lightning/)** — custom training loops wrapping HF models in LightningModule. +- **[ONNX](../onnx/)** — export HF models for optimized inference with ONNX Runtime. +- **[W&B](../wandb/)** — experiment tracking integrated with HF Trainer via `report_to`. +- **[AWS SageMaker](../aws-sagemaker/)** — deploy HF models on SageMaker with HF Deep Learning Containers. +- **[FastAPI](../fastapi/)** — serve HF pipelines behind async REST endpoints. diff --git a/skills/huggingface/SKILL.md b/skills/huggingface/SKILL.md new file mode 100644 index 0000000..ab96597 --- /dev/null +++ b/skills/huggingface/SKILL.md @@ -0,0 +1,530 @@ +--- +name: huggingface +description: > + Hugging Face ecosystem patterns for NLP and vision. Covers Transformers + models, datasets library, tokenizers, pipelines, fine-tuning with Trainer, + PEFT/LoRA adapters, model hub publishing, and inference optimization. +--- + +# Hugging Face Skill + +You are working with the Hugging Face ecosystem (Transformers, Datasets, Tokenizers, PEFT). Follow these patterns exactly. + +## Core Philosophy + +Hugging Face provides a unified API for loading, fine-tuning, and deploying pretrained models. Use `AutoModel` and `AutoTokenizer` classes for model loading — never hardcode model class names unless absolutely necessary. Use the `datasets` library for all data loading and preprocessing. Use the `Trainer` API for fine-tuning unless you need custom training loops. + +## Model Loading + +### AutoModel Pattern + +```python +"""Loading pretrained models with Hugging Face Transformers.""" + +from __future__ import annotations + +import torch +from transformers import ( + AutoConfig, + AutoModel, + AutoModelForImageClassification, + AutoModelForObjectDetection, + AutoModelForSequenceClassification, + AutoTokenizer, + AutoImageProcessor, +) +from pydantic import BaseModel, Field +from loguru import logger + + +class ModelConfig(BaseModel, frozen=True): + """Hugging Face model configuration.""" + + model_name: str = "microsoft/resnet-50" + revision: str = "main" + torch_dtype: str = "float32" + device_map: str | None = None + trust_remote_code: bool = False + cache_dir: str | None = None + + +def load_vision_model(config: ModelConfig) -> tuple: + """Load a vision model and its image processor.""" + logger.info("Loading model: {}", config.model_name) + + processor = AutoImageProcessor.from_pretrained( + config.model_name, + revision=config.revision, + cache_dir=config.cache_dir, + ) + + model = AutoModelForImageClassification.from_pretrained( + config.model_name, + revision=config.revision, + torch_dtype=getattr(torch, config.torch_dtype), + device_map=config.device_map, + trust_remote_code=config.trust_remote_code, + cache_dir=config.cache_dir, + ) + + logger.info("Model loaded: {} parameters", sum(p.numel() for p in model.parameters())) + return model, processor + + +def load_text_model(config: ModelConfig) -> tuple: + """Load a text model and tokenizer.""" + tokenizer = AutoTokenizer.from_pretrained( + config.model_name, + revision=config.revision, + cache_dir=config.cache_dir, + ) + + model = AutoModelForSequenceClassification.from_pretrained( + config.model_name, + revision=config.revision, + torch_dtype=getattr(torch, config.torch_dtype), + device_map=config.device_map, + cache_dir=config.cache_dir, + ) + + return model, tokenizer +``` + +## Datasets Library + +### Loading and Processing Datasets + +```python +"""Dataset loading and preprocessing with Hugging Face datasets.""" + +from __future__ import annotations + +from datasets import Dataset, DatasetDict, load_dataset +from transformers import AutoImageProcessor, AutoTokenizer +from loguru import logger + + +def load_image_dataset( + dataset_name: str, + processor: AutoImageProcessor, + split: str | None = None, +) -> DatasetDict | Dataset: + """Load and preprocess an image classification dataset.""" + dataset = load_dataset(dataset_name, split=split) + logger.info("Loaded dataset: {} rows", len(dataset) if isinstance(dataset, Dataset) else sum(len(s) for s in dataset.values())) + + def preprocess(batch: dict) -> dict: + images = batch["image"] + inputs = processor(images=images, return_tensors="pt") + inputs["labels"] = batch["label"] + return inputs + + processed = dataset.map( + preprocess, + batched=True, + batch_size=32, + remove_columns=dataset.column_names if isinstance(dataset, Dataset) else dataset["train"].column_names, + ) + + processed.set_format("torch") + return processed + + +def load_text_dataset( + dataset_name: str, + tokenizer: AutoTokenizer, + max_length: int = 512, +) -> DatasetDict: + """Load and tokenize a text dataset.""" + dataset = load_dataset(dataset_name) + + def tokenize(batch: dict) -> dict: + return tokenizer( + batch["text"], + padding="max_length", + truncation=True, + max_length=max_length, + return_tensors="pt", + ) + + tokenized = dataset.map(tokenize, batched=True, remove_columns=["text"]) + tokenized.set_format("torch") + return tokenized + + +def create_custom_dataset( + data_dir: str, + image_column: str = "image", + label_column: str = "label", +) -> Dataset: + """Create a dataset from a local directory of images.""" + dataset = load_dataset( + "imagefolder", + data_dir=data_dir, + ) + logger.info("Created dataset from {}: {} images", data_dir, len(dataset["train"])) + return dataset["train"] +``` + +## Fine-Tuning with Trainer + +### Standard Trainer Configuration + +```python +"""Fine-tuning with Hugging Face Trainer API.""" + +from __future__ import annotations + +from pathlib import Path + +import evaluate +import numpy as np +from pydantic import BaseModel, Field +from transformers import ( + AutoModelForImageClassification, + AutoImageProcessor, + EarlyStoppingCallback, + Trainer, + TrainingArguments, +) +from loguru import logger + + +class FinetuneConfig(BaseModel, frozen=True): + """Fine-tuning configuration.""" + + model_name: str = "microsoft/resnet-50" + output_dir: str = "outputs/finetuned" + num_train_epochs: int = 10 + per_device_train_batch_size: int = 32 + per_device_eval_batch_size: int = 64 + learning_rate: float = 5e-5 + weight_decay: float = 0.01 + warmup_ratio: float = 0.1 + fp16: bool = True + eval_strategy: str = "epoch" + save_strategy: str = "epoch" + load_best_model_at_end: bool = True + metric_for_best_model: str = "accuracy" + push_to_hub: bool = False + hub_model_id: str | None = None + + +def create_training_args(config: FinetuneConfig) -> TrainingArguments: + """Create TrainingArguments from config.""" + return TrainingArguments( + output_dir=config.output_dir, + num_train_epochs=config.num_train_epochs, + per_device_train_batch_size=config.per_device_train_batch_size, + per_device_eval_batch_size=config.per_device_eval_batch_size, + learning_rate=config.learning_rate, + weight_decay=config.weight_decay, + warmup_ratio=config.warmup_ratio, + fp16=config.fp16, + eval_strategy=config.eval_strategy, + save_strategy=config.save_strategy, + load_best_model_at_end=config.load_best_model_at_end, + metric_for_best_model=config.metric_for_best_model, + push_to_hub=config.push_to_hub, + hub_model_id=config.hub_model_id, + logging_steps=50, + dataloader_num_workers=4, + dataloader_pin_memory=True, + report_to=["tensorboard"], + ) + + +def compute_metrics(eval_pred) -> dict[str, float]: + """Compute accuracy for evaluation.""" + accuracy = evaluate.load("accuracy") + predictions, labels = eval_pred + predicted = np.argmax(predictions, axis=1) + return accuracy.compute(predictions=predicted, references=labels) + + +def finetune( + config: FinetuneConfig, + train_dataset, + eval_dataset, + num_labels: int, +) -> str: + """Fine-tune a pretrained model.""" + logger.info("Fine-tuning {} for {} epochs", config.model_name, config.num_train_epochs) + + model = AutoModelForImageClassification.from_pretrained( + config.model_name, + num_labels=num_labels, + ignore_mismatched_sizes=True, + ) + + training_args = create_training_args(config) + + trainer = Trainer( + model=model, + args=training_args, + train_dataset=train_dataset, + eval_dataset=eval_dataset, + compute_metrics=compute_metrics, + callbacks=[EarlyStoppingCallback(early_stopping_patience=3)], + ) + + trainer.train() + + # Save final model + output_path = Path(config.output_dir) / "best" + trainer.save_model(str(output_path)) + logger.info("Best model saved to {}", output_path) + + return str(output_path) +``` + +## PEFT / LoRA + +### Parameter-Efficient Fine-Tuning + +```python +"""PEFT fine-tuning with LoRA adapters.""" + +from __future__ import annotations + +from peft import ( + LoraConfig, + TaskType, + get_peft_model, + PeftModel, +) +from transformers import AutoModelForSequenceClassification +from loguru import logger + + +def create_lora_model( + model_name: str, + num_labels: int, + lora_r: int = 16, + lora_alpha: int = 32, + lora_dropout: float = 0.1, + target_modules: list[str] | None = None, +) -> PeftModel: + """Create a LoRA-adapted model for fine-tuning.""" + base_model = AutoModelForSequenceClassification.from_pretrained( + model_name, + num_labels=num_labels, + ) + + lora_config = LoraConfig( + task_type=TaskType.SEQ_CLS, + r=lora_r, + lora_alpha=lora_alpha, + lora_dropout=lora_dropout, + target_modules=target_modules or ["query", "value"], + bias="none", + ) + + model = get_peft_model(base_model, lora_config) + + trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) + total = sum(p.numel() for p in model.parameters()) + logger.info( + "LoRA model: {:.1f}M trainable / {:.1f}M total ({:.1f}%)", + trainable / 1e6, + total / 1e6, + 100 * trainable / total, + ) + + return model + + +def load_lora_model( + base_model_name: str, + adapter_path: str, + num_labels: int, +) -> PeftModel: + """Load a saved LoRA adapter on top of a base model.""" + base_model = AutoModelForSequenceClassification.from_pretrained( + base_model_name, + num_labels=num_labels, + ) + model = PeftModel.from_pretrained(base_model, adapter_path) + return model +``` + +## Pipelines for Quick Inference + +### Using Pipeline API + +```python +"""Hugging Face pipeline patterns for quick inference.""" + +from __future__ import annotations + +from transformers import pipeline + + +def create_image_classifier( + model_name: str = "microsoft/resnet-50", + device: int = 0, +) -> pipeline: + """Create an image classification pipeline.""" + return pipeline( + "image-classification", + model=model_name, + device=device, + ) + + +def create_object_detector( + model_name: str = "facebook/detr-resnet-50", + device: int = 0, + threshold: float = 0.5, +) -> pipeline: + """Create an object detection pipeline.""" + return pipeline( + "object-detection", + model=model_name, + device=device, + threshold=threshold, + ) + + +def create_zero_shot_classifier( + model_name: str = "openai/clip-vit-base-patch32", + device: int = 0, +) -> pipeline: + """Create a zero-shot image classification pipeline.""" + return pipeline( + "zero-shot-image-classification", + model=model_name, + device=device, + ) + + +# Usage +classifier = create_image_classifier() +results = classifier("path/to/image.jpg") +# [{"label": "cat", "score": 0.97}, {"label": "dog", "score": 0.02}] + +detector = create_object_detector() +results = detector("path/to/image.jpg") +# [{"label": "person", "score": 0.99, "box": {"xmin": 10, "ymin": 20, "xmax": 200, "ymax": 400}}] +``` + +## Model Hub Publishing + +### Pushing Models to the Hub + +```python +"""Publishing models and datasets to Hugging Face Hub.""" + +from __future__ import annotations + +from pathlib import Path + +from huggingface_hub import HfApi, ModelCard, ModelCardData +from transformers import AutoModel, AutoTokenizer +from loguru import logger + + +def push_model_to_hub( + model_path: str, + repo_id: str, + private: bool = True, +) -> str: + """Push a trained model to Hugging Face Hub.""" + model = AutoModel.from_pretrained(model_path) + model.push_to_hub(repo_id, private=private) + + # Push tokenizer/processor if present + try: + tokenizer = AutoTokenizer.from_pretrained(model_path) + tokenizer.push_to_hub(repo_id, private=private) + except Exception: + pass + + logger.info("Model pushed to hub: {}", repo_id) + return f"https://huggingface.co/{repo_id}" + + +def create_model_card( + repo_id: str, + model_name: str, + dataset_name: str, + metrics: dict[str, float], + language: str = "en", +) -> ModelCard: + """Create and push a model card.""" + card_data = ModelCardData( + language=language, + license="apache-2.0", + model_name=model_name, + datasets=[dataset_name], + metrics=list(metrics.keys()), + ) + + card = ModelCard.from_template( + card_data, + model_id=repo_id, + model_description=f"Fine-tuned {model_name} on {dataset_name}.", + training_metrics=metrics, + ) + + card.push_to_hub(repo_id) + logger.info("Model card pushed to: {}", repo_id) + return card +``` + +## Quantization and Optimization + +### Model Quantization for Faster Inference + +```python +"""Model quantization with Hugging Face Optimum.""" + +from __future__ import annotations + +import torch +from transformers import AutoModelForImageClassification, BitsAndBytesConfig +from loguru import logger + + +def load_quantized_model( + model_name: str, + num_labels: int, + load_in_4bit: bool = True, +) -> AutoModelForImageClassification: + """Load a 4-bit or 8-bit quantized model.""" + bnb_config = BitsAndBytesConfig( + load_in_4bit=load_in_4bit, + bnb_4bit_quant_type="nf4", + bnb_4bit_compute_dtype=torch.float16, + bnb_4bit_use_double_quant=True, + ) if load_in_4bit else BitsAndBytesConfig(load_in_8bit=True) + + model = AutoModelForImageClassification.from_pretrained( + model_name, + num_labels=num_labels, + quantization_config=bnb_config, + device_map="auto", + ) + + logger.info("Loaded quantized model ({} bit)", "4" if load_in_4bit else "8") + return model +``` + +## Anti-Patterns + +- **Never hardcode model class names** — use `AutoModel`, `AutoTokenizer`, `AutoImageProcessor` for flexibility. +- **Never download models inside training loops** — load once at initialization, cache with `cache_dir`. +- **Never skip `revision` parameter** — pin model versions for reproducibility. +- **Never tokenize the full dataset eagerly** — use `dataset.map(batched=True)` with lazy loading. +- **Never fine-tune all parameters when data is limited** — use PEFT/LoRA to reduce overfitting. +- **Never push models to public hub without a model card** — always include metrics, dataset, and license info. +- **Never ignore `trust_remote_code` warnings** — only set to `True` for trusted model sources. + +## Integration with Other Skills + +- **PyTorch Lightning** — Use Lightning Trainer for custom training loops with HF models. +- **W&B** — Pass `report_to=["wandb"]` in TrainingArguments for experiment tracking. +- **ONNX** — Export HF models via `optimum` for optimized inference. +- **AWS SageMaker** — Deploy HF models on SageMaker with the HF DLC (Deep Learning Container). +- **FastAPI** — Serve HF pipelines behind async API endpoints. +- **DVC** — Version datasets downloaded from Hugging Face Hub. diff --git a/skills/huggingface/skill.toml b/skills/huggingface/skill.toml new file mode 100644 index 0000000..bcf14fb --- /dev/null +++ b/skills/huggingface/skill.toml @@ -0,0 +1,17 @@ +[skill] +name = "huggingface" +version = "1.0.0" +category = "cv-ml" +tags = ["transformers", "datasets", "fine-tuning", "pretrained", "nlp", "vision"] + +[dependencies] +requires = ["pydantic-strict", "loguru"] +recommends = ["pytorch-lightning", "wandb", "onnx", "fastapi"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +transformers = ">=4.35" +datasets = ">=2.14" +peft = ">=0.6" diff --git a/skills/hydra-config/SKILL.md b/skills/hydra-config/SKILL.md index 552533b..f31063a 100644 --- a/skills/hydra-config/SKILL.md +++ b/skills/hydra-config/SKILL.md @@ -1,3 +1,11 @@ +--- +name: hydra-config +description: > + Manage complex hierarchical configurations for ML experiments using Hydra. + Covers structured configs, config composition, command-line overrides, multi-run + sweeps, config groups, and Pydantic validation integration. +--- + # Hydra Configuration Skill Use Hydra for managing complex, hierarchical configurations in machine learning and computer vision projects. This skill covers structured configs, config composition, command-line overrides, multi-run sweeps, and integration with Pydantic validation. diff --git a/skills/hydra-config/skill.toml b/skills/hydra-config/skill.toml new file mode 100644 index 0000000..a157fa8 --- /dev/null +++ b/skills/hydra-config/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "hydra-config" +version = "1.0.0" +category = "cv-ml" +tags = ["configuration", "hydra", "omegaconf", "experiment-management"] + +[dependencies] +requires = ["pydantic-strict"] +recommends = ["pytorch-lightning"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +hydra-core = ">=1.3" diff --git a/skills/kubernetes/README.md b/skills/kubernetes/README.md new file mode 100644 index 0000000..4dc5842 --- /dev/null +++ b/skills/kubernetes/README.md @@ -0,0 +1,35 @@ +# Kubernetes Skill + +The Kubernetes Skill provides production-grade deployment patterns for ML inference and training services on Kubernetes clusters. ML workloads have unique orchestration requirements: GPU scheduling with NVIDIA device plugins, long startup times for model loading, large persistent volumes for model weights, autoscaling based on inference throughput, and environment-specific configurations across dev, staging, and production. This skill addresses all of these with opinionated manifests, Helm charts, Kustomize overlays, and operational best practices tailored to computer vision and deep learning workloads. + +The skill covers the full deployment lifecycle: Deployment manifests with GPU resource requests and tolerations, Services and Ingress resources for routing traffic, Horizontal Pod Autoscalers tuned for inference workloads, ConfigMaps and Secrets for model configuration, PersistentVolumeClaims and init containers for model storage, Kubernetes Jobs for training runs, startup/liveness/readiness probes calibrated for slow model loading, namespace organization with resource quotas, and Kustomize overlays for managing environment differences without duplicating YAML. + +## Purpose + +When you need to deploy ML models to Kubernetes -- whether for real-time inference APIs, batch processing jobs, or GPU-accelerated training -- this skill provides the patterns and manifests to do it correctly. It encodes best practices for GPU scheduling, health checking, autoscaling, and multi-environment management that are specific to ML/CV workloads. + +## When to Use + +- When deploying ML inference services to a Kubernetes cluster (GKE, EKS, AKS, or on-premise). +- When you need GPU-accelerated pods with proper NVIDIA device plugin configuration and resource requests. +- When building Helm charts or Kustomize overlays for ML services across multiple environments. +- When configuring autoscaling, health probes, and persistent storage for model serving workloads. +- When orchestrating training jobs on Kubernetes with multi-GPU scheduling. + +## Key Features + +- **GPU resource scheduling** -- proper `nvidia.com/gpu` requests and limits with node selectors and tolerations for GPU node pools. +- **Health probes for ML** -- startup, liveness, and readiness probes calibrated for containers that take minutes to load large models. +- **Horizontal Pod Autoscaler** -- scaling policies tuned for inference workloads with stabilization windows and custom metrics. +- **Helm chart structure** -- complete chart layout with values files per environment for templated deployments. +- **Kustomize overlays** -- base manifests with dev, staging, and prod overlays to avoid YAML duplication. +- **Persistent model storage** -- PVCs and init containers for downloading model weights from object storage. +- **Namespace organization** -- environment separation with resource quotas to prevent GPU monopolization. +- **Training Jobs** -- Kubernetes Job manifests for one-off or scheduled training runs with GPU resources. + +## Related Skills + +- **[Docker CV](../docker-cv/)** -- container images deployed to Kubernetes are built using multi-stage Docker patterns with CUDA support. +- **[FastAPI](../fastapi/)** -- inference APIs running inside Kubernetes pods use FastAPI with health endpoints for probe integration. +- **[GCP](../gcp/)** -- GKE clusters, Artifact Registry for images, and Cloud Storage for model artifacts. +- **[AWS SageMaker](../aws-sagemaker/)** -- alternative managed deployment; Kubernetes provides more control when SageMaker constraints are too rigid. diff --git a/skills/kubernetes/SKILL.md b/skills/kubernetes/SKILL.md new file mode 100644 index 0000000..fcf521f --- /dev/null +++ b/skills/kubernetes/SKILL.md @@ -0,0 +1,776 @@ +--- +name: kubernetes +description: > + Kubernetes deployment patterns for ML inference and training services. Covers + GPU resource scheduling, Helm charts, Kustomize overlays, health probes, + autoscaling, persistent volumes for model storage, and namespace organization + for dev/staging/prod environments. +--- + +# Kubernetes Skill + +Kubernetes deployment patterns for ML inference and training services with GPU scheduling, Helm charts, autoscaling, and environment-specific overlays. + +## Deployment Manifests for ML Services + +Define Deployments with explicit resource requests and GPU scheduling for inference workloads. + +### Inference Deployment + +```yaml +# k8s/base/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: model-server + labels: + app: model-server + component: inference +spec: + replicas: 2 + selector: + matchLabels: + app: model-server + template: + metadata: + labels: + app: model-server + component: inference + spec: + containers: + - name: model-server + image: registry.example.com/ml-images/inference:v1.2.0 + ports: + - containerPort: 8000 + name: http + resources: + requests: + cpu: "2" + memory: "4Gi" + nvidia.com/gpu: "1" + limits: + cpu: "4" + memory: "8Gi" + nvidia.com/gpu: "1" + envFrom: + - configMapRef: + name: model-config + - secretRef: + name: model-secrets + volumeMounts: + - name: model-storage + mountPath: /models + readOnly: true + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health/ready + port: http + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 + startupProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 30 + volumes: + - name: model-storage + persistentVolumeClaim: + claimName: model-pvc + tolerations: + - key: nvidia.com/gpu + operator: Exists + effect: NoSchedule + nodeSelector: + cloud.google.com/gke-accelerator: nvidia-tesla-t4 +``` + +## GPU Resource Requests + +Always set both `requests` and `limits` for `nvidia.com/gpu`. Kubernetes GPU scheduling requires exact counts -- fractional GPUs are not natively supported. + +```yaml +# GPU resource patterns +resources: + requests: + nvidia.com/gpu: "1" # Request exactly 1 GPU + limits: + nvidia.com/gpu: "1" # Must equal requests for GPUs + +# Multi-GPU training pod +resources: + requests: + cpu: "8" + memory: "32Gi" + nvidia.com/gpu: "4" + limits: + cpu: "16" + memory: "64Gi" + nvidia.com/gpu: "4" +``` + +### NVIDIA Device Plugin + +The NVIDIA device plugin must be deployed in the cluster to enable GPU scheduling. + +```bash +# Install the NVIDIA device plugin via Helm +helm repo add nvdp https://nvidia.github.io/k8s-device-plugin +helm repo update + +helm install nvidia-device-plugin nvdp/nvidia-device-plugin \ + --namespace kube-system \ + --set runtimeClassName=nvidia +``` + +## Service and Ingress Configuration + +### Service + +```yaml +# k8s/base/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: model-server + labels: + app: model-server +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app: model-server +``` + +### Ingress + +```yaml +# k8s/base/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: model-server + annotations: + nginx.ingress.kubernetes.io/proxy-body-size: "50m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "120" + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + ingressClassName: nginx + tls: + - hosts: + - api.ml.example.com + secretName: model-server-tls + rules: + - host: api.ml.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: model-server + port: + name: http +``` + +## Horizontal Pod Autoscaler + +Scale inference pods based on CPU, memory, or custom metrics such as request latency or GPU utilization. + +```yaml +# k8s/base/hpa.yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: model-server +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: model-server + minReplicas: 2 + maxReplicas: 10 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Pods + value: 2 + periodSeconds: 60 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Pods + value: 1 + periodSeconds: 120 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + - type: Pods + pods: + metric: + name: inference_requests_per_second + target: + type: AverageValue + averageValue: "100" +``` + +## ConfigMaps and Secrets for Model Config + +### ConfigMap + +```yaml +# k8s/base/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: model-config +data: + MODEL_NAME: "resnet50" + MODEL_VERSION: "v1.2.0" + MODEL_PATH: "/models/resnet50_v1.2.0.onnx" + BATCH_SIZE: "8" + NUM_WORKERS: "4" + LOG_LEVEL: "INFO" + CONFIDENCE_THRESHOLD: "0.5" +``` + +### Secret + +```yaml +# k8s/base/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: model-secrets +type: Opaque +stringData: + WANDB_API_KEY: "" # Populated via Kustomize or sealed-secrets + S3_ACCESS_KEY: "" + S3_SECRET_KEY: "" +``` + +```bash +# Create secrets from command line (never commit plaintext secrets) +kubectl create secret generic model-secrets \ + --from-literal=WANDB_API_KEY="${WANDB_API_KEY}" \ + --from-literal=S3_ACCESS_KEY="${S3_ACCESS_KEY}" \ + --from-literal=S3_SECRET_KEY="${S3_SECRET_KEY}" \ + --namespace=ml-inference +``` + +## Persistent Volumes for Model Storage + +Use PersistentVolumeClaims to store large model weights independently of pod lifecycle. + +```yaml +# k8s/base/pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: model-pvc +spec: + accessModes: + - ReadOnlyMany + storageClassName: standard + resources: + requests: + storage: 50Gi +``` + +### Init Container to Download Models + +```yaml +# Use an init container to pull model weights before the main container starts +spec: + initContainers: + - name: model-downloader + image: google/cloud-sdk:slim + command: + - gsutil + - cp + - gs://my-ml-bucket/models/resnet50_v1.2.0.onnx + - /models/resnet50_v1.2.0.onnx + volumeMounts: + - name: model-storage + mountPath: /models + containers: + - name: model-server + image: registry.example.com/ml-images/inference:v1.2.0 + volumeMounts: + - name: model-storage + mountPath: /models + readOnly: true + volumes: + - name: model-storage + emptyDir: + sizeLimit: 50Gi +``` + +## Helm Chart Patterns + +Organize Kubernetes manifests into a Helm chart for templated, reusable deployments. + +### Chart Structure + +``` +helm/model-server/ +├── Chart.yaml +├── values.yaml +├── values-dev.yaml +├── values-staging.yaml +├── values-prod.yaml +└── templates/ + ├── _helpers.tpl + ├── deployment.yaml + ├── service.yaml + ├── ingress.yaml + ├── hpa.yaml + ├── configmap.yaml + ├── secret.yaml + ├── pvc.yaml + └── NOTES.txt +``` + +### values.yaml + +```yaml +# helm/model-server/values.yaml +replicaCount: 2 + +image: + repository: registry.example.com/ml-images/inference + tag: "v1.2.0" + pullPolicy: IfNotPresent + +model: + name: resnet50 + version: v1.2.0 + path: /models/resnet50_v1.2.0.onnx + storageSizeGi: 50 + +resources: + requests: + cpu: "2" + memory: "4Gi" + nvidia.com/gpu: "1" + limits: + cpu: "4" + memory: "8Gi" + nvidia.com/gpu: "1" + +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilization: 70 + +ingress: + enabled: true + host: api.ml.example.com + tls: true + +probes: + liveness: + path: /health + initialDelaySeconds: 30 + periodSeconds: 15 + readiness: + path: /health/ready + initialDelaySeconds: 60 + periodSeconds: 10 + startup: + path: /health + initialDelaySeconds: 10 + failureThreshold: 30 + +nodeSelector: + cloud.google.com/gke-accelerator: nvidia-tesla-t4 + +tolerations: + - key: nvidia.com/gpu + operator: Exists + effect: NoSchedule +``` + +### Helm Deployment Commands + +```bash +# Install the chart +helm install model-server ./helm/model-server \ + --namespace ml-inference \ + --create-namespace \ + --values helm/model-server/values-prod.yaml + +# Upgrade with new image tag +helm upgrade model-server ./helm/model-server \ + --namespace ml-inference \ + --set image.tag=v1.3.0 + +# Rollback to previous release +helm rollback model-server 1 --namespace ml-inference + +# Dry-run to preview rendered templates +helm template model-server ./helm/model-server \ + --values helm/model-server/values-staging.yaml \ + --debug +``` + +## Health Checks (Liveness, Readiness, and Startup Probes) + +ML containers need long startup times for model loading. Use all three probe types. + +```yaml +# Startup probe: allow up to 5 minutes for model loading (10s * 30 attempts) +startupProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 30 + +# Liveness probe: restart if unresponsive +livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 0 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + +# Readiness probe: remove from service if not ready +readinessProbe: + httpGet: + path: /health/ready + port: 8000 + initialDelaySeconds: 0 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 +``` + +### FastAPI Health Endpoints + +```python +from fastapi import FastAPI, Response, status + +app = FastAPI() + +model_loaded = False + + +@app.get("/health") +def health() -> dict[str, str]: + """Liveness probe -- is the process alive?""" + return {"status": "alive"} + + +@app.get("/health/ready") +def readiness(response: Response) -> dict[str, str]: + """Readiness probe -- is the model loaded and ready for inference?""" + if not model_loaded: + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return {"status": "not_ready", "reason": "model loading"} + return {"status": "ready"} +``` + +## Namespace Organization + +Separate environments and workload types using namespaces with resource quotas. + +```bash +# Create namespaces for each environment +kubectl create namespace ml-dev +kubectl create namespace ml-staging +kubectl create namespace ml-prod + +# Create namespace for training jobs +kubectl create namespace ml-training +``` + +### Resource Quotas per Namespace + +```yaml +# k8s/namespaces/ml-prod-quota.yaml +apiVersion: v1 +kind: ResourceQuota +metadata: + name: gpu-quota + namespace: ml-prod +spec: + hard: + requests.nvidia.com/gpu: "8" + limits.nvidia.com/gpu: "8" + requests.cpu: "32" + requests.memory: "128Gi" + persistentvolumeclaims: "20" +``` + +## Kustomize Overlays for Dev/Staging/Prod + +Use Kustomize to manage environment-specific variations without duplicating manifests. + +### Directory Structure + +``` +k8s/ +├── base/ +│ ├── kustomization.yaml +│ ├── deployment.yaml +│ ├── service.yaml +│ ├── ingress.yaml +│ ├── hpa.yaml +│ ├── configmap.yaml +│ └── pvc.yaml +└── overlays/ + ├── dev/ + │ ├── kustomization.yaml + │ └── patches/ + │ ├── deployment-patch.yaml + │ └── hpa-patch.yaml + ├── staging/ + │ ├── kustomization.yaml + │ └── patches/ + │ └── deployment-patch.yaml + └── prod/ + ├── kustomization.yaml + └── patches/ + ├── deployment-patch.yaml + └── hpa-patch.yaml +``` + +### Base Kustomization + +```yaml +# k8s/base/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + - ingress.yaml + - hpa.yaml + - configmap.yaml + - pvc.yaml +commonLabels: + app.kubernetes.io/name: model-server + app.kubernetes.io/managed-by: kustomize +``` + +### Dev Overlay + +```yaml +# k8s/overlays/dev/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: ml-dev +resources: + - ../../base +patches: + - path: patches/deployment-patch.yaml + - path: patches/hpa-patch.yaml +images: + - name: registry.example.com/ml-images/inference + newTag: dev-latest +``` + +```yaml +# k8s/overlays/dev/patches/deployment-patch.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: model-server +spec: + replicas: 1 + template: + spec: + containers: + - name: model-server + resources: + requests: + cpu: "1" + memory: "2Gi" + nvidia.com/gpu: "1" + limits: + cpu: "2" + memory: "4Gi" + nvidia.com/gpu: "1" +``` + +```yaml +# k8s/overlays/dev/patches/hpa-patch.yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: model-server +spec: + minReplicas: 1 + maxReplicas: 2 +``` + +### Prod Overlay + +```yaml +# k8s/overlays/prod/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: ml-prod +resources: + - ../../base +patches: + - path: patches/deployment-patch.yaml + - path: patches/hpa-patch.yaml +images: + - name: registry.example.com/ml-images/inference + newTag: v1.2.0 +``` + +```yaml +# k8s/overlays/prod/patches/deployment-patch.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: model-server +spec: + replicas: 3 + template: + spec: + containers: + - name: model-server + resources: + requests: + cpu: "4" + memory: "8Gi" + nvidia.com/gpu: "1" + limits: + cpu: "8" + memory: "16Gi" + nvidia.com/gpu: "1" +``` + +### Apply Overlays + +```bash +# Apply dev overlay +kubectl apply -k k8s/overlays/dev/ + +# Apply staging overlay +kubectl apply -k k8s/overlays/staging/ + +# Apply prod overlay +kubectl apply -k k8s/overlays/prod/ + +# Preview rendered manifests without applying +kubectl kustomize k8s/overlays/prod/ +``` + +## Training Jobs with Kubernetes + +Use Kubernetes Jobs for one-off training runs. + +```yaml +# k8s/jobs/training-job.yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: training-resnet50 + namespace: ml-training +spec: + backoffLimit: 2 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: OnFailure + containers: + - name: trainer + image: registry.example.com/ml-images/training:v1.2.0 + command: ["python", "-m", "my_project.train"] + args: + - "--config=configs/train.yaml" + - "--epochs=100" + - "--batch-size=32" + resources: + requests: + cpu: "8" + memory: "32Gi" + nvidia.com/gpu: "4" + limits: + cpu: "16" + memory: "64Gi" + nvidia.com/gpu: "4" + volumeMounts: + - name: data + mountPath: /data + readOnly: true + - name: checkpoints + mountPath: /checkpoints + envFrom: + - secretRef: + name: training-secrets + volumes: + - name: data + persistentVolumeClaim: + claimName: training-data-pvc + - name: checkpoints + persistentVolumeClaim: + claimName: checkpoints-pvc + tolerations: + - key: nvidia.com/gpu + operator: Exists + effect: NoSchedule + nodeSelector: + cloud.google.com/gke-accelerator: nvidia-tesla-a100 +``` + +## Anti-Patterns to Avoid + +- Do not omit GPU resource limits -- without explicit `nvidia.com/gpu` limits, pods will not be scheduled on GPU nodes and will silently fall back to CPU. +- Do not use `latest` image tags in production -- always pin to a specific version tag or Git SHA for reproducibility and safe rollbacks. +- Do not skip startup probes for ML containers -- model loading can take minutes; without a startup probe, Kubernetes will kill pods before they are ready. +- Do not store model weights inside container images -- images become multi-gigabyte and slow to pull; use PVCs or init containers to download models. +- Do not set CPU requests too low for GPU pods -- GPU inference still requires CPU for preprocessing; under-provisioned CPU starves the pipeline. +- Do not hardcode environment-specific values in base manifests -- use Kustomize overlays or Helm values files for dev/staging/prod differences. +- Do not run pods as root -- always set `runAsNonRoot: true` in the pod security context. +- Do not skip resource quotas on shared clusters -- without quotas, a single team can monopolize all GPU resources. +- Do not expose inference services without TLS -- always terminate TLS at the Ingress or use a service mesh. +- Do not ignore pod disruption budgets -- set `minAvailable` to prevent all replicas from being evicted during node upgrades. + +## Best Practices + +1. **Always set resource requests and limits** -- GPU, CPU, and memory must all be specified for predictable scheduling. +2. **Use startup probes** -- ML containers need extended initialization time; startup probes prevent premature restarts. +3. **Separate namespaces by environment** -- dev, staging, and prod with resource quotas per namespace. +4. **Use Kustomize or Helm for environment management** -- never duplicate YAML across environments. +5. **Pin image tags** -- use semantic versions or Git SHAs, never `latest` in staging or prod. +6. **Run as non-root** -- set `securityContext.runAsNonRoot: true` on all pods. +7. **Use PVCs for model storage** -- decouple model weights from container images. +8. **Set pod disruption budgets** -- ensure minimum availability during cluster maintenance. +9. **Configure HPA with appropriate metrics** -- scale on request rate or GPU utilization, not just CPU. +10. **Use init containers for model downloads** -- pull weights from object storage before the main container starts. diff --git a/skills/kubernetes/skill.toml b/skills/kubernetes/skill.toml new file mode 100644 index 0000000..e49777c --- /dev/null +++ b/skills/kubernetes/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "kubernetes" +version = "1.0.0" +category = "infra" +tags = ["kubernetes", "k8s", "deployment", "orchestration", "helm"] + +[dependencies] +requires = ["docker-cv"] +recommends = ["fastapi", "gcp", "aws-sagemaker"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/library-review/SKILL.md b/skills/library-review/SKILL.md index 29f3bba..8b4dee7 100644 --- a/skills/library-review/SKILL.md +++ b/skills/library-review/SKILL.md @@ -1,3 +1,11 @@ +--- +name: library-review +description: > + Structured framework for evaluating third-party Python libraries before adoption. + Covers technology radar classification (Adopt/Trial/Assess/Hold), dependency risk + assessment, API wrapping strategies, and security review checklists. +--- + # Library Review and Evaluation Framework ## Overview diff --git a/skills/library-review/skill.toml b/skills/library-review/skill.toml new file mode 100644 index 0000000..df0b044 --- /dev/null +++ b/skills/library-review/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "library-review" +version = "1.0.0" +category = "core" +tags = ["evaluation", "library-selection", "architecture-decisions"] + +[dependencies] +requires = [] +recommends = ["master-skill"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/loguru/SKILL.md b/skills/loguru/SKILL.md index 3e4ae75..c8582c0 100644 --- a/skills/loguru/SKILL.md +++ b/skills/loguru/SKILL.md @@ -1,3 +1,11 @@ +--- +name: loguru +description: > + Structured logging for CV/ML projects using Loguru as a mandatory convention. + Covers sink configuration, structured JSON logging, log levels, context binding, + and replacing stdlib logging and print statements. +--- + # Loguru Skill Structured logging for CV/ML projects using loguru. This is a mandatory project convention — all repositories use loguru instead of stdlib `logging` or `print()`. diff --git a/skills/loguru/skill.toml b/skills/loguru/skill.toml new file mode 100644 index 0000000..06c7966 --- /dev/null +++ b/skills/loguru/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "loguru" +version = "1.0.0" +category = "core" +tags = ["logging", "structured-logging", "observability"] + +[dependencies] +requires = [] +recommends = ["master-skill"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +loguru = ">=0.7" diff --git a/skills/master-skill/SKILL.md b/skills/master-skill/SKILL.md index fa5e305..043e0ec 100644 --- a/skills/master-skill/SKILL.md +++ b/skills/master-skill/SKILL.md @@ -1,6 +1,14 @@ +--- +name: master-skill +description: > + Orchestrates new AI/CV project initialization using the whet framework. + Guides archetype selection, skill composition, directory scaffolding, and initial + configuration for new computer vision and deep learning projects. +--- + # Master Skill: Project Initialization -You are initializing a new AI/CV project using the ai-cv-claude-skills framework. +You are initializing a new AI/CV project using the whet framework. ## Initialization Process diff --git a/skills/master-skill/skill.toml b/skills/master-skill/skill.toml new file mode 100644 index 0000000..6811d69 --- /dev/null +++ b/skills/master-skill/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "master-skill" +version = "1.0.0" +category = "core" +tags = ["conventions", "python", "coding-standards"] + +[dependencies] +requires = [] +recommends = ["pydantic-strict", "code-quality", "loguru"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/matplotlib/SKILL.md b/skills/matplotlib/SKILL.md index 860708f..8e1d0b8 100644 --- a/skills/matplotlib/SKILL.md +++ b/skills/matplotlib/SKILL.md @@ -1,3 +1,11 @@ +--- +name: matplotlib +description: > + Comprehensive visualization patterns for ML and computer vision using Matplotlib. + Covers training curves, confusion matrices, image grids, bounding box overlays, + feature maps, publication-quality figures, and experiment tracker integration. +--- + # Matplotlib Skill Comprehensive visualization patterns for ML and computer vision projects. This skill covers training curves, confusion matrices, image grids, bounding box overlays, feature maps, dimensionality reduction plots, publication-quality figures, and integration with experiment tracking. diff --git a/skills/matplotlib/skill.toml b/skills/matplotlib/skill.toml new file mode 100644 index 0000000..aee91dd --- /dev/null +++ b/skills/matplotlib/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "matplotlib" +version = "1.0.0" +category = "cv-ml" +tags = ["visualization", "plotting", "charts", "figures"] + +[dependencies] +requires = [] +recommends = ["opencv"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +matplotlib = ">=3.8" diff --git a/skills/mlflow/SKILL.md b/skills/mlflow/SKILL.md index 37ca4a0..79c4709 100644 --- a/skills/mlflow/SKILL.md +++ b/skills/mlflow/SKILL.md @@ -1,3 +1,11 @@ +--- +name: mlflow +description: > + MLflow integration for experiment tracking, model registry, and model serving in + ML projects. Covers self-hosted setup, metric logging, artifact management, + model versioning, and opt-in integration patterns. +--- + # MLflow Tracking Integration for ML Projects ## Overview diff --git a/skills/mlflow/skill.toml b/skills/mlflow/skill.toml new file mode 100644 index 0000000..d10e54c --- /dev/null +++ b/skills/mlflow/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "mlflow" +version = "1.0.0" +category = "experiment-tracking" +tags = ["experiment-tracking", "model-registry", "mlflow", "deployment"] + +[dependencies] +requires = ["loguru"] +recommends = ["pytorch-lightning"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +mlflow = ">=2.10" diff --git a/skills/onnx/SKILL.md b/skills/onnx/SKILL.md index 7a12f58..7217147 100644 --- a/skills/onnx/SKILL.md +++ b/skills/onnx/SKILL.md @@ -1,3 +1,11 @@ +--- +name: onnx +description: > + Export PyTorch models to ONNX format for optimized inference with ONNX Runtime. + Covers dynamic axes configuration, model optimization, graph surgery, validation + against PyTorch outputs, and production deployment patterns. +--- + # ONNX Model Export and Inference ## Overview diff --git a/skills/onnx/skill.toml b/skills/onnx/skill.toml new file mode 100644 index 0000000..44a8865 --- /dev/null +++ b/skills/onnx/skill.toml @@ -0,0 +1,16 @@ +[skill] +name = "onnx" +version = "1.0.0" +category = "cv-ml" +tags = ["model-export", "inference", "onnx", "optimization"] + +[dependencies] +requires = ["loguru"] +recommends = ["tensorrt", "pytorch-lightning"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +onnx = ">=1.15" +onnxruntime = ">=1.17" diff --git a/skills/opencv/SKILL.md b/skills/opencv/SKILL.md index b7029e9..8811b2d 100644 --- a/skills/opencv/SKILL.md +++ b/skills/opencv/SKILL.md @@ -1,3 +1,11 @@ +--- +name: opencv +description: > + Type-safe OpenCV abstractions for computer vision projects. Covers video I/O, + image processing wrappers, drawing utilities, color space conversions, camera + capture, and clean API design over OpenCV's C-style interface. +--- + # OpenCV Skill Comprehensive OpenCV abstractions for computer vision projects. This skill provides clean, type-safe wrappers around OpenCV's C-style API, covering video reading/writing, image abstractions, drawing utilities, color space conversions, and camera capture. diff --git a/skills/opencv/skill.toml b/skills/opencv/skill.toml new file mode 100644 index 0000000..4463509 --- /dev/null +++ b/skills/opencv/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "opencv" +version = "1.0.0" +category = "cv-ml" +tags = ["computer-vision", "image-processing", "video", "opencv"] + +[dependencies] +requires = ["abstraction-patterns"] +recommends = ["matplotlib"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +opencv-python = ">=4.8" diff --git a/skills/pixi/SKILL.md b/skills/pixi/SKILL.md index a3bb03e..6c50fca 100644 --- a/skills/pixi/SKILL.md +++ b/skills/pixi/SKILL.md @@ -1,3 +1,11 @@ +--- +name: pixi +description: > + Manage Python project environments and dependencies using Pixi. Covers pixi.toml + configuration, conda/PyPI dependency management, task definitions, lock files, + cross-platform environments, and CUDA toolkit setup. +--- + # Pixi Skill You are managing Python project environments and dependencies using pixi. Follow these patterns exactly. diff --git a/skills/pixi/skill.toml b/skills/pixi/skill.toml new file mode 100644 index 0000000..3eda6ab --- /dev/null +++ b/skills/pixi/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "pixi" +version = "1.0.0" +category = "infra" +tags = ["package-management", "environment", "conda", "pixi"] + +[dependencies] +requires = [] +recommends = ["code-quality"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/pre-commit/SKILL.md b/skills/pre-commit/SKILL.md index 206bed7..b507706 100644 --- a/skills/pre-commit/SKILL.md +++ b/skills/pre-commit/SKILL.md @@ -1,3 +1,11 @@ +--- +name: pre-commit +description: > + Configure pre-commit hooks for Python and ML projects to enforce code quality + at commit time. Covers Ruff, MyPy, YAML validation, large file prevention, + secret detection, and CI integration with pre-commit.ci. +--- + # Pre-commit Hooks for Python and ML Projects ## Overview diff --git a/skills/pre-commit/skill.toml b/skills/pre-commit/skill.toml new file mode 100644 index 0000000..378b794 --- /dev/null +++ b/skills/pre-commit/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "pre-commit" +version = "1.0.0" +category = "infra" +tags = ["git-hooks", "automation", "pre-commit", "code-quality"] + +[dependencies] +requires = ["code-quality"] +recommends = ["testing"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/pydantic-strict/SKILL.md b/skills/pydantic-strict/SKILL.md index 6e3f4af..5dbb92c 100644 --- a/skills/pydantic-strict/SKILL.md +++ b/skills/pydantic-strict/SKILL.md @@ -1,3 +1,11 @@ +--- +name: pydantic-strict +description: > + Configuration and data validation using Pydantic V2 with strict typing for AI/CV + projects. Covers frozen models, discriminated unions, custom validators, settings + management, and load-time validation patterns. +--- + # Pydantic Strict Skill You are writing configuration and data validation code using Pydantic V2 with strict typing. Follow these patterns exactly. diff --git a/skills/pydantic-strict/skill.toml b/skills/pydantic-strict/skill.toml new file mode 100644 index 0000000..24ef7a3 --- /dev/null +++ b/skills/pydantic-strict/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "pydantic-strict" +version = "1.0.0" +category = "core" +tags = ["validation", "config", "pydantic", "data-models"] + +[dependencies] +requires = [] +recommends = ["master-skill"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +pydantic = ">=2.0" diff --git a/skills/pypi/SKILL.md b/skills/pypi/SKILL.md index 45bab8b..7e8f819 100644 --- a/skills/pypi/SKILL.md +++ b/skills/pypi/SKILL.md @@ -1,3 +1,11 @@ +--- +name: pypi +description: > + Complete workflow for packaging and publishing Python projects to PyPI. Covers + pyproject.toml configuration, src layout, version management, build system setup, + trusted publishers via GitHub Actions, and TestPyPI staging. +--- + # PyPI Publishing Skill Complete workflow for packaging and publishing Python projects to PyPI. This skill covers `pyproject.toml` configuration, version management, build system setup, src layout, package metadata, entry points, dependency specification, building, publishing to PyPI and TestPyPI, trusted publishers via GitHub Actions, and release automation. diff --git a/skills/pypi/skill.toml b/skills/pypi/skill.toml new file mode 100644 index 0000000..840f44f --- /dev/null +++ b/skills/pypi/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "pypi" +version = "1.0.0" +category = "infra" +tags = ["packaging", "publishing", "pypi", "distribution"] + +[dependencies] +requires = ["code-quality"] +recommends = ["github-actions"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/pytorch-lightning/SKILL.md b/skills/pytorch-lightning/SKILL.md index e338a0c..214e6c9 100644 --- a/skills/pytorch-lightning/SKILL.md +++ b/skills/pytorch-lightning/SKILL.md @@ -1,3 +1,11 @@ +--- +name: pytorch-lightning +description: > + PyTorch Lightning patterns for AI/CV training pipelines. Covers LightningModule + design, LightningDataModule, Trainer configuration, callbacks, logging integration, + distributed training, and checkpoint management. +--- + # PyTorch Lightning Skill You are writing PyTorch Lightning code for AI/CV projects. Follow these patterns exactly. diff --git a/skills/pytorch-lightning/skill.toml b/skills/pytorch-lightning/skill.toml new file mode 100644 index 0000000..eccd97e --- /dev/null +++ b/skills/pytorch-lightning/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "pytorch-lightning" +version = "1.0.0" +category = "cv-ml" +tags = ["training", "deep-learning", "pytorch", "lightning"] + +[dependencies] +requires = ["pydantic-strict", "loguru"] +recommends = ["hydra-config", "wandb"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +pytorch-lightning = ">=2.0" diff --git a/skills/tensorboard/SKILL.md b/skills/tensorboard/SKILL.md index 4b45063..b3207a7 100644 --- a/skills/tensorboard/SKILL.md +++ b/skills/tensorboard/SKILL.md @@ -1,3 +1,11 @@ +--- +name: tensorboard +description: > + TensorBoard visualization and logging for PyTorch ML projects. Covers scalar + metrics, image logging, model graph visualization, histogram tracking, profiling, + and zero-setup local experiment monitoring. +--- + # TensorBoard Logging for ML Projects ## Overview diff --git a/skills/tensorboard/skill.toml b/skills/tensorboard/skill.toml new file mode 100644 index 0000000..28d8997 --- /dev/null +++ b/skills/tensorboard/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "tensorboard" +version = "1.0.0" +category = "experiment-tracking" +tags = ["visualization", "tensorboard", "training-monitoring"] + +[dependencies] +requires = ["loguru"] +recommends = ["pytorch-lightning"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +tensorboard = ">=2.14" diff --git a/skills/tensorrt/SKILL.md b/skills/tensorrt/SKILL.md index 8e7fa2f..48c9314 100644 --- a/skills/tensorrt/SKILL.md +++ b/skills/tensorrt/SKILL.md @@ -1,3 +1,11 @@ +--- +name: tensorrt +description: > + Maximize inference performance on NVIDIA GPUs by converting ONNX models to TensorRT + engines. Covers precision modes (FP16/INT8), dynamic shapes, engine building, + calibration, benchmarking, and Triton Inference Server deployment. +--- + # TensorRT Skill Maximize inference performance on NVIDIA GPUs by converting ONNX models to TensorRT engines. This skill requires the ONNX skill — always export and slim with ONNX first, then convert to TensorRT. diff --git a/skills/tensorrt/skill.toml b/skills/tensorrt/skill.toml new file mode 100644 index 0000000..014dec9 --- /dev/null +++ b/skills/tensorrt/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "tensorrt" +version = "1.0.0" +category = "cv-ml" +tags = ["gpu-inference", "nvidia", "tensorrt", "optimization"] + +[dependencies] +requires = ["onnx"] +recommends = ["docker-cv"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +tensorrt = ">=8.6" diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md index 07241cc..19e75ec 100644 --- a/skills/testing/SKILL.md +++ b/skills/testing/SKILL.md @@ -1,3 +1,11 @@ +--- +name: testing +description: > + Comprehensive pytest patterns for ML and computer vision projects. Covers test + structure, fixtures, parametrized tests, CV-specific assertions, tensor shape + validation, mocking, performance testing, and CI integration. +--- + # Testing Skill Comprehensive pytest patterns for ML and computer vision projects. This skill covers test structure, fixtures, parametrized tests, CV-specific testing strategies, mocking, performance testing, and CI integration. diff --git a/skills/testing/skill.toml b/skills/testing/skill.toml new file mode 100644 index 0000000..f416e26 --- /dev/null +++ b/skills/testing/skill.toml @@ -0,0 +1,16 @@ +[skill] +name = "testing" +version = "1.0.0" +category = "core" +tags = ["testing", "pytest", "coverage", "tdd"] + +[dependencies] +requires = ["pydantic-strict"] +recommends = ["code-quality"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +pytest = ">=7.4" +pytest-cov = ">=4.1" diff --git a/skills/vscode/SKILL.md b/skills/vscode/SKILL.md index 77b6dfe..bb3bc0f 100644 --- a/skills/vscode/SKILL.md +++ b/skills/vscode/SKILL.md @@ -1,3 +1,11 @@ +--- +name: vscode +description: > + Configure VS Code for productive computer vision and ML development. Covers + workspace settings, Ruff and MyPy integration, Python debugging configurations, + remote SSH GPU server setup, and recommended extensions. +--- + # VS Code Skill Configure VS Code for productive computer vision and machine learning development with integrated linting, debugging, and remote GPU server support. diff --git a/skills/vscode/skill.toml b/skills/vscode/skill.toml new file mode 100644 index 0000000..17abb8a --- /dev/null +++ b/skills/vscode/skill.toml @@ -0,0 +1,13 @@ +[skill] +name = "vscode" +version = "1.0.0" +category = "infra" +tags = ["editor", "ide", "vscode", "configuration"] + +[dependencies] +requires = [] +recommends = ["code-quality"] + +[compatibility] +python = ">=3.11" +libraries = {} diff --git a/skills/wandb/SKILL.md b/skills/wandb/SKILL.md index 1792fc4..23a0746 100644 --- a/skills/wandb/SKILL.md +++ b/skills/wandb/SKILL.md @@ -1,3 +1,11 @@ +--- +name: wandb +description: > + Weights & Biases (W&B) integration for ML experiment tracking and collaboration. + Covers metric logging, artifact management, hyperparameter sweeps, model registry, + dataset versioning, and opt-in graceful degradation patterns. +--- + # Weights & Biases (W&B) Integration for ML Projects ## Overview diff --git a/skills/wandb/skill.toml b/skills/wandb/skill.toml new file mode 100644 index 0000000..6e52c17 --- /dev/null +++ b/skills/wandb/skill.toml @@ -0,0 +1,15 @@ +[skill] +name = "wandb" +version = "1.0.0" +category = "experiment-tracking" +tags = ["experiment-tracking", "visualization", "wandb", "metrics"] + +[dependencies] +requires = ["loguru"] +recommends = ["pytorch-lightning"] + +[compatibility] +python = ">=3.11" + +[compatibility.libraries] +wandb = ">=0.16" diff --git a/src/whet/__init__.py b/src/whet/__init__.py new file mode 100644 index 0000000..2cb7ff7 --- /dev/null +++ b/src/whet/__init__.py @@ -0,0 +1,5 @@ +"""whet — Sharpen your AI coder.""" + +from __future__ import annotations + +__version__ = "0.1.0" diff --git a/src/whet/__main__.py b/src/whet/__main__.py new file mode 100644 index 0000000..22ae91a --- /dev/null +++ b/src/whet/__main__.py @@ -0,0 +1,7 @@ +"""Allow running whet as `python -m whet`.""" + +from __future__ import annotations + +from whet.cli import app + +app() diff --git a/src/whet/adapters/__init__.py b/src/whet/adapters/__init__.py new file mode 100644 index 0000000..6d1956a --- /dev/null +++ b/src/whet/adapters/__init__.py @@ -0,0 +1,3 @@ +"""Platform adapters for installing skills to different AI coding agents.""" + +from __future__ import annotations diff --git a/src/whet/adapters/antigravity.py b/src/whet/adapters/antigravity.py new file mode 100644 index 0000000..6093d9c --- /dev/null +++ b/src/whet/adapters/antigravity.py @@ -0,0 +1,51 @@ +"""Google Antigravity adapter — native SKILL.md support.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from whet.core.skill import Skill + + +class AntigravityAdapter: + """Adapter for Google Antigravity. + + Antigravity also uses SKILL.md natively, so this adapter copies + skill directories to the Antigravity skills location. + """ + + @property + def name(self) -> str: + return "antigravity" + + def install_skill(self, skill: Skill, target_dir: Path) -> Path: + """Copy the skill directory to Antigravity's skills location.""" + dest = target_dir / skill.name + dest.mkdir(parents=True, exist_ok=True) + + src_skill_md = skill.skill_md_path + if src_skill_md.exists(): + shutil.copy2(src_skill_md, dest / "SKILL.md") + + src_readme = skill.path / "README.md" + if src_readme.exists(): + shutil.copy2(src_readme, dest / "README.md") + + return dest + + def remove_skill(self, skill_name: str, target_dir: Path) -> bool: + """Remove a skill directory from Antigravity's skills location.""" + skill_dir = target_dir / skill_name + if skill_dir.is_dir(): + shutil.rmtree(skill_dir) + return True + return False + + def list_installed(self, target_dir: Path) -> list[str]: + """List installed skills.""" + if not target_dir.is_dir(): + return [] + return sorted( + d.name for d in target_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists() + ) diff --git a/src/whet/adapters/base.py b/src/whet/adapters/base.py new file mode 100644 index 0000000..7d53b82 --- /dev/null +++ b/src/whet/adapters/base.py @@ -0,0 +1,35 @@ +"""Base adapter protocol for platform-specific skill installation.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + +from whet.core.skill import Skill + + +class PlatformAdapter(Protocol): + """Protocol for platform-specific skill installation.""" + + @property + def name(self) -> str: + """Platform name.""" + ... + + def install_skill(self, skill: Skill, target_dir: Path) -> Path: + """Install a skill to the target directory. + + Returns the path where the skill was installed. + """ + ... + + def remove_skill(self, skill_name: str, target_dir: Path) -> bool: + """Remove an installed skill. + + Returns True if the skill was found and removed. + """ + ... + + def list_installed(self, target_dir: Path) -> list[str]: + """List names of installed skills.""" + ... diff --git a/src/whet/adapters/claude.py b/src/whet/adapters/claude.py new file mode 100644 index 0000000..5491a6a --- /dev/null +++ b/src/whet/adapters/claude.py @@ -0,0 +1,53 @@ +"""Claude Code adapter — native SKILL.md support.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from whet.core.skill import Skill + + +class ClaudeAdapter: + """Adapter for Claude Code. + + Claude Code natively uses SKILL.md files, so the adapter simply copies + the skill directory to the target location. + """ + + @property + def name(self) -> str: + return "claude" + + def install_skill(self, skill: Skill, target_dir: Path) -> Path: + """Copy the skill directory to Claude's skills location.""" + dest = target_dir / skill.name + dest.mkdir(parents=True, exist_ok=True) + + # Copy SKILL.md (the primary file the LLM reads) + src_skill_md = skill.skill_md_path + if src_skill_md.exists(): + shutil.copy2(src_skill_md, dest / "SKILL.md") + + # Copy README.md if present + src_readme = skill.path / "README.md" + if src_readme.exists(): + shutil.copy2(src_readme, dest / "README.md") + + return dest + + def remove_skill(self, skill_name: str, target_dir: Path) -> bool: + """Remove a skill directory from Claude's skills location.""" + skill_dir = target_dir / skill_name + if skill_dir.is_dir(): + shutil.rmtree(skill_dir) + return True + return False + + def list_installed(self, target_dir: Path) -> list[str]: + """List installed skills by scanning for SKILL.md files.""" + if not target_dir.is_dir(): + return [] + return sorted( + d.name for d in target_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists() + ) diff --git a/src/whet/adapters/copilot.py b/src/whet/adapters/copilot.py new file mode 100644 index 0000000..8bb4a79 --- /dev/null +++ b/src/whet/adapters/copilot.py @@ -0,0 +1,98 @@ +"""GitHub Copilot adapter — aggregates skills into a single instructions file.""" + +from __future__ import annotations + +from pathlib import Path + +from whet.core.skill import Skill + + +class CopilotAdapter: + """Adapter for GitHub Copilot. + + Copilot uses a single .github/copilot-instructions.md file. + Skills are aggregated into sections within this file. + """ + + INSTRUCTIONS_FILE = "copilot-instructions.md" + MARKER_START = "" + MARKER_END = "" + + @property + def name(self) -> str: + return "copilot" + + def install_skill(self, skill: Skill, target_dir: Path) -> Path: + """Append skill content to the Copilot instructions file. + + Note: For Copilot, use install_skills() to batch-install, + as each skill is a section in a single file. + """ + target_dir.mkdir(parents=True, exist_ok=True) + dest = target_dir / self.INSTRUCTIONS_FILE + content = skill.read_skill_md() + content = _strip_frontmatter(content) + + start = f"" + end = f"" + section = f"\n\n{start}\n{content}\n{end}\n" + + if dest.exists(): + existing = dest.read_text() + # Replace existing section or append + start_marker = f"" + end_marker = f"" + if start_marker in existing: + before = existing[: existing.index(start_marker)] + after = existing[existing.index(end_marker) + len(end_marker) :] + dest.write_text(before + section.strip() + after) + else: + dest.write_text(existing + section) + else: + header = f"{self.MARKER_START}\n# AI Coding Skills (managed by whet)\n" + dest.write_text(header + section + f"\n{self.MARKER_END}\n") + + return dest + + def remove_skill(self, skill_name: str, target_dir: Path) -> bool: + """Remove a skill section from the Copilot instructions file.""" + dest = target_dir / self.INSTRUCTIONS_FILE + if not dest.exists(): + return False + + content = dest.read_text() + start_marker = f"" + end_marker = f"" + + if start_marker not in content: + return False + + before = content[: content.index(start_marker)] + after = content[content.index(end_marker) + len(end_marker) :] + dest.write_text(before.rstrip() + after.lstrip("\n")) + return True + + def list_installed(self, target_dir: Path) -> list[str]: + """List installed skills by scanning for whet markers.""" + dest = target_dir / self.INSTRUCTIONS_FILE + if not dest.exists(): + return [] + + content = dest.read_text() + names: list[str] = [] + for line in content.split("\n"): + if line.strip().startswith(""): + name = line.strip().removeprefix("") + names.append(name) + return sorted(names) + + +def _strip_frontmatter(content: str) -> str: + """Remove YAML frontmatter from markdown content.""" + if not content.startswith("---"): + return content + lines = content.split("\n") + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + return "\n".join(lines[i + 1 :]).lstrip("\n") + return content diff --git a/src/whet/adapters/cursor.py b/src/whet/adapters/cursor.py new file mode 100644 index 0000000..1a4aa50 --- /dev/null +++ b/src/whet/adapters/cursor.py @@ -0,0 +1,58 @@ +"""Cursor adapter — converts SKILL.md to Cursor rules format.""" + +from __future__ import annotations + +from pathlib import Path + +from whet.core.skill import Skill + + +class CursorAdapter: + """Adapter for Cursor. + + Cursor uses .cursor/rules/ with markdown rule files. + Strips YAML frontmatter and writes as .md rule files. + """ + + @property + def name(self) -> str: + return "cursor" + + def install_skill(self, skill: Skill, target_dir: Path) -> Path: + """Write skill as a Cursor rule file.""" + target_dir.mkdir(parents=True, exist_ok=True) + dest = target_dir / f"{skill.name}.md" + + content = skill.read_skill_md() + # Strip YAML frontmatter for Cursor + content = _strip_frontmatter(content) + + dest.write_text(content) + return dest + + def remove_skill(self, skill_name: str, target_dir: Path) -> bool: + """Remove a Cursor rule file.""" + rule_file = target_dir / f"{skill_name}.md" + if rule_file.is_file(): + rule_file.unlink() + return True + return False + + def list_installed(self, target_dir: Path) -> list[str]: + """List installed rules by scanning .md files.""" + if not target_dir.is_dir(): + return [] + return sorted(f.stem for f in target_dir.glob("*.md")) + + +def _strip_frontmatter(content: str) -> str: + """Remove YAML frontmatter from markdown content.""" + if not content.startswith("---"): + return content + + lines = content.split("\n") + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + return "\n".join(lines[i + 1 :]).lstrip("\n") + + return content diff --git a/src/whet/adapters/detect.py b/src/whet/adapters/detect.py new file mode 100644 index 0000000..25c0000 --- /dev/null +++ b/src/whet/adapters/detect.py @@ -0,0 +1,30 @@ +"""Auto-detect platform from project directory markers.""" + +from __future__ import annotations + +from pathlib import Path + +from whet.core.config import Platform + +# Detection priority order +_MARKERS: list[tuple[str, Platform]] = [ + (".claude", Platform.CLAUDE), + (".agent", Platform.ANTIGRAVITY), + (".cursor", Platform.CURSOR), + (".github", Platform.COPILOT), +] + + +def detect_platform(project_dir: Path | None = None) -> Platform | None: + """Detect the AI platform from project directory markers. + + Checks for platform-specific directories in priority order. + Returns None if no platform markers are found. + """ + root = project_dir or Path.cwd() + + for marker, platform in _MARKERS: + if (root / marker).is_dir(): + return platform + + return None diff --git a/src/whet/cli/__init__.py b/src/whet/cli/__init__.py new file mode 100644 index 0000000..b52b387 --- /dev/null +++ b/src/whet/cli/__init__.py @@ -0,0 +1,64 @@ +"""whet CLI — Sharpen your AI coder.""" + +from __future__ import annotations + +import typer +from rich.console import Console + +from whet import __version__ + +console = Console() + +app = typer.Typer( + name="whet", + help="Sharpen your AI coder — install expert skills into AI coding agents.", + no_args_is_help=True, + add_completion=False, + rich_markup_mode="rich", +) + +BANNER = r""" + [bold cyan]██╗ ██╗██╗ ██╗███████╗████████╗[/bold cyan] + [bold cyan]██║ ██║██║ ██║██╔════╝╚══██╔══╝[/bold cyan] + [bold cyan]██║ █╗ ██║███████║█████╗ ██║[/bold cyan] + [bold cyan]██║███╗██║██╔══██║██╔══╝ ██║[/bold cyan] + [bold cyan]╚███╔███╔╝██║ ██║███████╗ ██║[/bold cyan] + [bold cyan] ╚══╝╚══╝ ╚═╝ ╚═╝╚══════╝ ╚═╝[/bold cyan] + [dim]sharpen your AI coder[/dim] +""" + + +def version_callback(value: bool) -> None: # noqa: FBT001 + """Print version and exit.""" + if value: + console.print(BANNER) + console.print(f" [bold]v{__version__}[/bold] · claude · antigravity · cursor\n") + raise typer.Exit() + + +@app.callback() +def main( + version: bool | None = typer.Option( # noqa: UP007 + None, + "--version", + "-v", + help="Show version and exit.", + callback=version_callback, + is_eager=True, + ), +) -> None: + """whet — Sharpen your AI coder. + + Install expert skills into AI coding agents (Claude Code, Antigravity, Cursor, Copilot). + """ + + +# Import and register sub-commands +from whet.cli import config as _config_mod # noqa: E402, F401 +from whet.cli import doctor as _doctor_mod # noqa: E402, F401 +from whet.cli import init as _init_mod # noqa: E402, F401 +from whet.cli import install as _install_mod # noqa: E402, F401 +from whet.cli import settings as _settings_mod # noqa: E402, F401 +from whet.cli import skills as _skills_mod # noqa: E402, F401 +from whet.cli import target as _target_mod # noqa: E402, F401 +from whet.cli import update as _update_mod # noqa: E402, F401 diff --git a/src/whet/cli/config.py b/src/whet/cli/config.py new file mode 100644 index 0000000..6149ecb --- /dev/null +++ b/src/whet/cli/config.py @@ -0,0 +1,49 @@ +"""Config command — get/set whet configuration.""" + +from __future__ import annotations + +import typer +from rich.console import Console + +from whet.cli import app +from whet.core.config import Platform + +console = Console() + + +@app.command() +def config( + key: str | None = typer.Argument(None, help="Configuration key."), # noqa: UP007 + value: str | None = typer.Argument(None, help="Value to set."), # noqa: UP007 +) -> None: + """Get or set whet configuration. + + Without arguments, shows all configuration. + With a key, shows that value. With key and value, sets it. + """ + from whet.core.config import WhetConfig + + cfg = WhetConfig() + + if key is None: + console.print("[bold]whet configuration[/bold]\n") + console.print(f" target: {cfg.target.value}") + console.print(f" skills_dir: {cfg.skills_dir}") + console.print(f" agents_dir: {cfg.agents_dir}") + console.print(f" archetypes_dir: {cfg.archetypes_dir}") + return + + if key == "target": + if value: + try: + platform = Platform(value) + except ValueError as err: + valid = ", ".join(p.value for p in Platform) + console.print(f"[red]Invalid platform '{value}'. Valid: {valid}[/red]") + raise typer.Exit(code=1) from err + console.print(f"[green]✓[/green] target = {platform.value}") + else: + console.print(f"target = {cfg.target.value}") + else: + console.print(f"[yellow]Unknown config key: {key}[/yellow]") + raise typer.Exit(code=1) diff --git a/src/whet/cli/doctor.py b/src/whet/cli/doctor.py new file mode 100644 index 0000000..c7abbac --- /dev/null +++ b/src/whet/cli/doctor.py @@ -0,0 +1,181 @@ +"""Doctor command — health check for whet installation.""" + +from __future__ import annotations + +import typer +from rich.console import Console + +from whet.adapters.detect import detect_platform +from whet.cli import app +from whet.core.config import PLATFORM_PATHS, WhetConfig +from whet.registry.loader import discover_skills + +console = Console() + + +def _check_mark(ok: bool) -> str: + return "[green]✓[/green]" if ok else "[red]✗[/red]" + + +def _warn_mark() -> str: + return "[yellow]![/yellow]" + + +@app.command() +def doctor() -> None: + """Run health checks on your whet installation.""" + console.print("[bold]whet doctor[/bold]\n") + issues = 0 + warnings = 0 + + cfg = WhetConfig() + + # Check 1: Skills directory + if cfg.skills_dir.is_dir(): + skills = discover_skills(cfg.skills_dir) + n = len(skills) + console.print(f" {_check_mark(True)} Skills directory: {cfg.skills_dir} ({n} skills)") + else: + console.print(f" {_check_mark(False)} Skills directory not found: {cfg.skills_dir}") + issues += 1 + skills = [] + + # Check 2: Agents directory + if cfg.agents_dir.is_dir(): + agents = [ + d.name for d in cfg.agents_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists() + ] + n_agents = len(agents) + console.print( + f" {_check_mark(True)} Agents directory: {cfg.agents_dir} ({n_agents} agents)" + ) + else: + console.print(f" {_warn_mark()} Agents directory not found: {cfg.agents_dir}") + warnings += 1 + agents = [] + + # Check 3: Archetypes directory + if cfg.archetypes_dir.is_dir(): + archetypes = [ + d.name + for d in cfg.archetypes_dir.iterdir() + if d.is_dir() and (d / "README.md").exists() + ] + n_arch = len(archetypes) + console.print( + f" {_check_mark(True)} Archetypes directory: " + f"{cfg.archetypes_dir} ({n_arch} archetypes)" + ) + else: + console.print(f" {_warn_mark()} Archetypes directory not found: {cfg.archetypes_dir}") + warnings += 1 + + # Check 4: Platform detection + detected = detect_platform() + if detected: + console.print(f" {_check_mark(True)} Platform detected: {detected.value}") + else: + console.print(f" {_warn_mark()} No platform auto-detected in current directory") + warnings += 1 + + # Check 5: Installed skills + if detected: + from whet.cli.skills import _get_adapter + + adapter = _get_adapter(detected) + paths = PLATFORM_PATHS[detected] + + lc = len(adapter.list_installed(paths.local_dir)) + gc = len(adapter.list_installed(paths.global_dir)) + + if lc > 0 or gc > 0: + console.print(f" {_check_mark(True)} Installed: {lc} local, {gc} global") + else: + console.print(f" {_warn_mark()} No skills installed for {detected.value}") + warnings += 1 + + # Check 6: SKILL.md frontmatter + if skills: + missing_fm = [s for s in skills if not s.description] + if missing_fm: + n_fm = len(missing_fm) + console.print(f" {_warn_mark()} {n_fm} skills missing YAML frontmatter") + for s in missing_fm[:5]: + console.print(f" - {s.name}") + warnings += 1 + else: + console.print(f" {_check_mark(True)} All skills have YAML frontmatter") + + # Check 7: skill.toml files + if skills: + missing_toml = [s for s in skills if not s.has_toml] + if missing_toml: + n_toml = len(missing_toml) + console.print(f" {_warn_mark()} {n_toml} skills missing skill.toml") + for s in missing_toml[:5]: + console.print(f" - {s.name}") + warnings += 1 + else: + console.print(f" {_check_mark(True)} All skills have skill.toml metadata") + + # Check 8: agent.toml files + if agents: + missing_agent_toml = [a for a in agents if not (cfg.agents_dir / a / "agent.toml").exists()] + if missing_agent_toml: + n_at = len(missing_agent_toml) + console.print(f" {_warn_mark()} {n_at} agents missing agent.toml") + for a in missing_agent_toml[:5]: + console.print(f" - {a}") + warnings += 1 + else: + console.print(f" {_check_mark(True)} All agents have agent.toml metadata") + + # Check 9: Settings template + from whet.settings.engine import get_settings_target, get_template_path + + plat = detected.value if detected else cfg.target.value + template_path = get_template_path(plat) + if template_path.exists(): + console.print(f" {_check_mark(True)} Settings template: {template_path.name}") + + # Check if settings are applied + local_target = get_settings_target(plat, "local") + if local_target.exists(): + console.print(f" {_check_mark(True)} Local settings: {local_target}") + else: + console.print( + f" {_warn_mark()} No local settings. Run: [bold]whet settings apply[/bold]" + ) + warnings += 1 + else: + console.print(f" {_warn_mark()} No settings template for {plat}") + warnings += 1 + + # Check 10: Dependency consistency + if skills: + available = {s.name for s in skills} + broken_deps = [] + for skill in skills: + if skill.metadata and skill.metadata.dependencies.requires: + for dep in skill.metadata.dependencies.requires: + if dep not in available: + broken_deps.append((skill.name, dep)) + + if broken_deps: + n_bd = len(broken_deps) + console.print(f" {_warn_mark()} {n_bd} broken dependency reference(s)") + for skill_name, dep in broken_deps[:5]: + console.print(f" - {skill_name} requires '{dep}' (not found)") + warnings += 1 + else: + console.print(f" {_check_mark(True)} All skill dependencies resolve") + + # Summary + console.print() + if issues: + console.print(f"[red]{issues} error(s), {warnings} warning(s)[/red]") + raise typer.Exit(code=1) + elif warnings: + console.print(f"[yellow]{warnings} warning(s), 0 errors[/yellow]") + else: + console.print("[bold green]All checks passed.[/bold green]") diff --git a/src/whet/cli/init.py b/src/whet/cli/init.py new file mode 100644 index 0000000..ba44828 --- /dev/null +++ b/src/whet/cli/init.py @@ -0,0 +1,116 @@ +"""Init command — scaffold a new project from an archetype.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console +from rich.table import Table + +from whet.cli import app +from whet.core.config import WhetConfig +from whet.scaffold.engine import ( + ScaffoldContext, + discover_archetypes, + load_archetype, + render_template, +) + +console = Console() + + +@app.command(name="init") +def init_project( + archetype_name: str | None = typer.Argument( # noqa: UP007 + None, help="Archetype to scaffold from." + ), + output_dir: str | None = typer.Option( # noqa: UP007 + None, "--output", "-o", help="Output directory (default: current dir)." + ), + project_name: str | None = typer.Option( # noqa: UP007 + None, "--name", "-n", help="Project name." + ), + author: str | None = typer.Option( # noqa: UP007 + None, "--author", "-a", help="Author name." + ), +) -> None: + """Scaffold a new project from an archetype template. + + Without arguments, lists available archetypes. + """ + cfg = WhetConfig() + + if archetype_name is None: + _list_archetypes(cfg) + return + + archetype = load_archetype(cfg.archetypes_dir / archetype_name) + if not archetype: + console.print(f"[red]Archetype '{archetype_name}' not found.[/red]") + console.print("[dim]Run 'whet init' to see available archetypes.[/dim]") + raise typer.Exit(code=1) + + name = project_name or archetype_name + dest = Path(output_dir) if output_dir else Path.cwd() / name + + if dest.exists() and any(dest.iterdir()): + console.print(f"[red]Directory '{dest}' already exists and is not empty.[/red]") + raise typer.Exit(code=1) + + context = ScaffoldContext( + project_name=name, + description=archetype.metadata.description, + author=author or "", + ) + + console.print(f"[bold]Scaffolding: {archetype.metadata.name}[/bold]\n") + console.print(f" Project: {context.project_name}") + console.print(f" Package: {context.package_name}") + console.print(f" Directory: {dest}") + console.print() + + render_template(archetype, dest, context) + + console.print(f"[bold green]✓ Project scaffolded at {dest}[/bold green]\n") + + # Show required skills + if archetype.skills.required: + console.print("[bold]Required skills:[/bold]") + for skill in archetype.skills.required: + console.print(f" - {skill}") + console.print( + f"\nRun: [bold]cd {dest.name} && whet add {' '.join(archetype.skills.required)}[/bold]" + ) + + if archetype.skills.recommended: + console.print("\n[bold]Recommended skills:[/bold]") + for skill in archetype.skills.recommended: + console.print(f" - {skill}") + + +def _list_archetypes(cfg: WhetConfig) -> None: + """List available archetypes.""" + archetypes = discover_archetypes(cfg.archetypes_dir) + + if not archetypes: + console.print("[dim]No archetypes found.[/dim]") + raise typer.Exit() + + table = Table(title="Available Archetypes") + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Category", style="green") + table.add_column("Description") + table.add_column("Template", style="dim") + + for arch in archetypes: + has_tmpl = "yes" if arch.has_template else "minimal" + table.add_row( + arch.metadata.name, + arch.metadata.category, + arch.metadata.description, + has_tmpl, + ) + + console.print(table) + console.print("\n[dim]Usage: whet init [/dim]") diff --git a/src/whet/cli/install.py b/src/whet/cli/install.py new file mode 100644 index 0000000..8cc399b --- /dev/null +++ b/src/whet/cli/install.py @@ -0,0 +1,133 @@ +"""Install command — bulk install skills, agents, and settings to a platform.""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console + +from whet.adapters.base import PlatformAdapter +from whet.cli import app +from whet.core.config import Platform, WhetConfig +from whet.core.skill import Skill +from whet.registry.loader import discover_agents, discover_skills + +console = Console() + + +@app.command() +def install( + scope_global: bool = typer.Option(False, "--global", "-g", help="Install to global directory."), + scope_local: bool = typer.Option(False, "--local", "-l", help="Install to local project."), + category: str | None = typer.Option(None, "--category", "--cat", help="Only install category."), + skills_only: bool = typer.Option(False, "--skills-only", help="Only install skills."), + agents_only: bool = typer.Option(False, "--agents-only", help="Only install agents."), + with_settings: bool = typer.Option( + False, "--with-settings", "-s", help="Also apply settings template." + ), +) -> None: + """Install skills, agents, and optionally settings.""" + if not scope_global and not scope_local: + scope_local = True + + cfg = _get_config() + adapter = _get_adapter(cfg.target) + paths = cfg.get_platform_paths() + + include_skills = not agents_only + include_agents = not skills_only + + all_skills: list[Skill] = [] + all_agents: list[Skill] = [] + + if include_skills: + all_skills = discover_skills(cfg.skills_dir) + if category: + all_skills = [s for s in all_skills if s.category == category] + + if include_agents: + all_agents = discover_agents(cfg.agents_dir) + + if not all_skills and not all_agents: + console.print("[yellow]No skills or agents found to install.[/yellow]") + raise typer.Exit(code=1) + + if scope_global: + if all_skills: + _install_to(adapter, all_skills, paths.global_dir, "skills", "global") + if all_agents: + _install_to(adapter, all_agents, paths.global_dir, "agents", "global") + if with_settings: + _apply_settings(cfg.target.value, "global") + + if scope_local: + if all_skills: + _install_to(adapter, all_skills, paths.local_dir, "skills", "local") + if all_agents: + _install_to(adapter, all_agents, paths.local_dir, "agents", "local") + if with_settings: + _apply_settings(cfg.target.value, "local") + + +def _install_to( + adapter: PlatformAdapter, + items: list[Skill], + target_dir: Path, + item_type: str, + scope_label: str, +) -> None: + """Install skills or agents to a target directory.""" + console.print(f"\n[bold]Installing {len(items)} {item_type} ({scope_label})...[/bold]") + + for item in items: + adapter.install_skill(item, target_dir) + console.print(f" [green]✓[/green] {item.name}") + + console.print( + f"\n[bold green]✓ Installed {len(items)} {item_type} to {target_dir}[/bold green]" + ) + + +def _apply_settings(platform: str, scope: str) -> None: + """Apply settings template with merge support.""" + from whet.settings.engine import ( + get_settings_target, + get_template_path, + load_existing, + load_template, + merge_settings, + write_settings, + ) + + template_path = get_template_path(platform) + settings_path = get_settings_target(platform, scope) + template = load_template(template_path) + existing = load_existing(settings_path) + + if existing: + merged = merge_settings(existing, template) + write_settings(settings_path, merged) + console.print(f"\n[bold green]✓ Merged settings into {settings_path}[/bold green]") + else: + write_settings(settings_path, template) + console.print(f"\n[bold green]✓ Applied settings to {settings_path}[/bold green]") + + +def _get_config() -> WhetConfig: + return WhetConfig() + + +def _get_adapter(platform: Platform) -> PlatformAdapter: + from whet.adapters.antigravity import AntigravityAdapter + from whet.adapters.claude import ClaudeAdapter + from whet.adapters.copilot import CopilotAdapter + from whet.adapters.cursor import CursorAdapter + + adapters: dict[Platform, type[PlatformAdapter]] = { + Platform.CLAUDE: ClaudeAdapter, + Platform.ANTIGRAVITY: AntigravityAdapter, + Platform.CURSOR: CursorAdapter, + Platform.COPILOT: CopilotAdapter, + } + return adapters[platform]() diff --git a/src/whet/cli/settings.py b/src/whet/cli/settings.py new file mode 100644 index 0000000..4cf5c7b --- /dev/null +++ b/src/whet/cli/settings.py @@ -0,0 +1,172 @@ +"""Settings commands — generate, apply, and diff platform settings.""" + +from __future__ import annotations + +import typer +from rich.console import Console +from rich.table import Table + +from whet.cli import app +from whet.core.config import Platform, WhetConfig +from whet.settings.engine import ( + diff_settings, + get_settings_target, + get_template_path, + load_existing, + load_template, + merge_settings, + write_settings, +) + +console = Console() + +settings_app = typer.Typer( + name="settings", + help="Manage platform settings (permissions, allowed commands).", + no_args_is_help=True, +) +app.add_typer(settings_app) + + +@settings_app.command() +def generate( + platform: str | None = typer.Option( # noqa: UP007 + None, "--platform", "-p", help="Target platform." + ), +) -> None: + """Generate optimized settings for a platform.""" + cfg = WhetConfig() + plat = platform or cfg.target.value + + try: + Platform(plat) + except ValueError as err: + valid = ", ".join(p.value for p in Platform) + console.print(f"[red]Invalid platform '{plat}'. Valid: {valid}[/red]") + raise typer.Exit(code=1) from err + + template_path = get_template_path(plat) + + if not template_path.exists(): + console.print(f"[yellow]No settings template for '{plat}'.[/yellow]") + console.print("[dim]Available templates:[/dim]") + templates_dir = template_path.parent + for f in sorted(templates_dir.glob("*.json")): + console.print(f" {f.stem}") + raise typer.Exit(code=1) + + template = load_template(template_path) + n = len(template.permissions.get("allow", [])) + + console.print(f"[bold]Generated settings for {plat}[/bold]\n") + console.print(f" Permissions: {n} allow rules") + console.print(f" Template: {template_path}") + console.print("\n Run [bold]whet settings diff[/bold] to review") + console.print(" Run [bold]whet settings apply[/bold] to write") + + +@settings_app.command() +def diff( + platform: str | None = typer.Option( # noqa: UP007 + None, "--platform", "-p", help="Target platform." + ), + scope: str = typer.Option("local", "--scope", "-s", help="Scope: local or global."), +) -> None: + """Preview changes between current settings and template.""" + cfg = WhetConfig() + plat = platform or cfg.target.value + + template_path = get_template_path(plat) + if not template_path.exists(): + console.print(f"[red]No template for '{plat}'.[/red]") + raise typer.Exit(code=1) + + template = load_template(template_path) + target_path = get_settings_target(plat, scope) + existing = load_existing(target_path) + + new_entries, kept_entries, all_merged = diff_settings(existing, template) + + console.print(f"[bold]Settings diff for {plat} ({scope})[/bold]\n") + console.print(f" Target: {target_path}") + + if existing: + console.print(f" Current: {len(kept_entries)} permissions") + else: + console.print(" Current: [dim]no file[/dim]") + + console.print(f" Template: {len(template.permissions.get('allow', []))} permissions") + console.print(f" Result: {len(all_merged)} permissions\n") + + if new_entries: + table = Table(title="New permissions to add") + table.add_column("Permission", style="green") + for entry in new_entries: + table.add_row(f"+ {entry}") + console.print(table) + else: + console.print(" [dim]No new permissions — settings are up to date.[/dim]") + + +@settings_app.command() +def apply( + platform: str | None = typer.Option( # noqa: UP007 + None, "--platform", "-p", help="Target platform." + ), + scope_global: bool = typer.Option(False, "--global", "-g", help="Apply to global settings."), + scope_local: bool = typer.Option( + False, "--local", "-l", help="Apply to local project settings." + ), + force: bool = typer.Option( + False, "--force", "-f", help="Overwrite existing settings entirely." + ), +) -> None: + """Apply settings template to platform configuration. + + By default merges with existing settings (keeps current permissions, + adds new ones from template). Use --force to replace entirely. + """ + if not scope_global and not scope_local: + scope_local = True + + cfg = WhetConfig() + plat = platform or cfg.target.value + + template_path = get_template_path(plat) + if not template_path.exists(): + console.print(f"[red]No template for '{plat}'.[/red]") + raise typer.Exit(code=1) + + template = load_template(template_path) + + scopes = [] + if scope_global: + scopes.append("global") + if scope_local: + scopes.append("local") + + for scope in scopes: + target_path = get_settings_target(plat, scope) + existing = load_existing(target_path) + + if existing and not force: + merged = merge_settings(existing, template) + new_entries, _, _ = diff_settings(existing, template) + write_settings(target_path, merged) + n_new = len(new_entries) + n_total = len(merged.permissions.get("allow", [])) + console.print( + f" [green]✓[/green] Merged {scope}: +{n_new} new, {n_total} total → {target_path}" + ) + elif existing and force: + # Back up existing + backup = target_path.with_suffix(".json.bak") + backup.write_text(target_path.read_text()) + write_settings(target_path, template) + n = len(template.permissions.get("allow", [])) + console.print(f" [green]✓[/green] Replaced {scope}: {n} permissions → {target_path}") + console.print(f" [dim]Backup: {backup}[/dim]") + else: + write_settings(target_path, template) + n = len(template.permissions.get("allow", [])) + console.print(f" [green]✓[/green] Created {scope}: {n} permissions → {target_path}") diff --git a/src/whet/cli/skills.py b/src/whet/cli/skills.py new file mode 100644 index 0000000..e009c3f --- /dev/null +++ b/src/whet/cli/skills.py @@ -0,0 +1,200 @@ +"""Skill management commands: add, remove, list, search, info.""" + +from __future__ import annotations + +import typer +from rich.console import Console +from rich.table import Table + +from whet.cli import app +from whet.core.config import Platform, WhetConfig +from whet.registry.loader import discover_skills, load_skill +from whet.registry.resolver import resolve_dependencies +from whet.registry.search import search_skills + +console = Console() + + +def _get_config() -> WhetConfig: + return WhetConfig() + + +def _get_adapter(platform: Platform): # type: ignore[no-untyped-def] + from whet.adapters.antigravity import AntigravityAdapter + from whet.adapters.claude import ClaudeAdapter + from whet.adapters.copilot import CopilotAdapter + from whet.adapters.cursor import CursorAdapter + + adapters = { + Platform.CLAUDE: ClaudeAdapter, + Platform.ANTIGRAVITY: AntigravityAdapter, + Platform.CURSOR: CursorAdapter, + Platform.COPILOT: CopilotAdapter, + } + return adapters[platform]() + + +@app.command() +def add( + skills: list[str] = typer.Argument(help="Skill name(s) to add."), + scope: bool = typer.Option(False, "--global", "-g", help="Install globally."), +) -> None: + """Add skill(s) to the current project or globally.""" + cfg = _get_config() + adapter = _get_adapter(cfg.target) + all_skills = discover_skills(cfg.skills_dir) + available = {s.name: s for s in all_skills} + + # Resolve dependencies + to_install = resolve_dependencies(skills, available) + + paths = cfg.get_platform_paths() + target_dir = paths.global_dir if scope else paths.local_dir + + installed_count = 0 + for name in to_install: + skill = available.get(name) + if not skill: + console.print(f"[yellow]Warning:[/yellow] Skill '{name}' not found, skipping.") + continue + adapter.install_skill(skill, target_dir) + extra = " (dependency)" if name not in skills else "" + console.print(f" [green]✓[/green] {name}{extra}") + installed_count += 1 + + console.print(f"\n[bold]Installed {installed_count} skill(s) to {target_dir}[/bold]") + + +@app.command() +def remove( + skill_name: str = typer.Argument(help="Skill name to remove."), + scope: bool = typer.Option(False, "--global", "-g", help="Remove from global installation."), +) -> None: + """Remove a skill from the current project or global installation.""" + cfg = _get_config() + adapter = _get_adapter(cfg.target) + paths = cfg.get_platform_paths() + target_dir = paths.global_dir if scope else paths.local_dir + + if adapter.remove_skill(skill_name, target_dir): + console.print(f" [green]✓[/green] Removed {skill_name}") + else: + console.print(f" [red]✗[/red] Skill '{skill_name}' not found in {target_dir}") + raise typer.Exit(code=1) + + +@app.command(name="list") +def list_skills( + installed: bool = typer.Option(False, "--installed", "-i", help="Show only installed skills."), + category: str | None = typer.Option(None, "--category", "--cat", help="Filter by category."), # noqa: UP007 +) -> None: + """Browse available skills.""" + cfg = _get_config() + + if installed: + adapter = _get_adapter(cfg.target) + paths = cfg.get_platform_paths() + # Check both local and global + local_installed = adapter.list_installed(paths.local_dir) + global_installed = adapter.list_installed(paths.global_dir) + all_installed = sorted(set(local_installed + global_installed)) + + if not all_installed: + console.print("[dim]No skills installed.[/dim]") + raise typer.Exit() + + table = Table(title="Installed Skills") + table.add_column("Name", style="cyan") + table.add_column("Scope", style="green") + + for name in all_installed: + scope_label = [] + if name in local_installed: + scope_label.append("local") + if name in global_installed: + scope_label.append("global") + table.add_row(name, ", ".join(scope_label)) + + console.print(table) + return + + all_skills = discover_skills(cfg.skills_dir) + if category: + all_skills = [s for s in all_skills if s.category == category] + + if not all_skills: + console.print("[dim]No skills found.[/dim]") + raise typer.Exit() + + table = Table(title="Available Skills") + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Category", style="green") + table.add_column("Description") + + for skill in all_skills: + desc = skill.description[:80] + "..." if len(skill.description) > 80 else skill.description + table.add_row(skill.name, skill.category, desc) + + console.print(table) + console.print(f"\n[dim]{len(all_skills)} skills available[/dim]") + + +@app.command() +def search( + query: str = typer.Argument(help="Search query (matches name, description, tags)."), +) -> None: + """Search skills by name, description, or tags.""" + cfg = _get_config() + all_skills = discover_skills(cfg.skills_dir) + results = search_skills(all_skills, query=query) + + if not results: + console.print(f"[dim]No skills matching '{query}'[/dim]") + raise typer.Exit() + + table = Table(title=f"Search results for '{query}'") + table.add_column("Name", style="cyan", no_wrap=True) + table.add_column("Category", style="green") + table.add_column("Tags", style="dim") + table.add_column("Description") + + for skill in results: + tags = ", ".join(skill.tags[:4]) + desc = skill.description[:60] + "..." if len(skill.description) > 60 else skill.description + table.add_row(skill.name, skill.category, tags, desc) + + console.print(table) + + +@app.command() +def info( + skill_name: str = typer.Argument(help="Skill name."), +) -> None: + """Show detailed information about a skill.""" + cfg = _get_config() + skill = load_skill(cfg.skills_dir, skill_name) + + if not skill: + console.print(f"[red]Skill '{skill_name}' not found.[/red]") + raise typer.Exit(code=1) + + console.print(f"\n[bold cyan]{skill.name}[/bold cyan]") + console.print(f"[dim]{skill.description}[/dim]\n") + + if skill.metadata: + m = skill.metadata + console.print(f" [bold]Category:[/bold] {m.category}") + console.print(f" [bold]Version:[/bold] {m.version}") + console.print(f" [bold]Tags:[/bold] {', '.join(m.tags)}") + + if m.dependencies.requires: + console.print(f" [bold]Requires:[/bold] {', '.join(m.dependencies.requires)}") + if m.dependencies.recommends: + console.print(f" [bold]Recommends:[/bold] {', '.join(m.dependencies.recommends)}") + + if m.compatibility.libraries: + console.print(" [bold]Libraries:[/bold]") + for lib, ver in m.compatibility.libraries.items(): + console.print(f" {lib} {ver}") + + console.print(f"\n [dim]Path: {skill.path}[/dim]") diff --git a/src/whet/cli/target.py b/src/whet/cli/target.py new file mode 100644 index 0000000..5e0bcb7 --- /dev/null +++ b/src/whet/cli/target.py @@ -0,0 +1,44 @@ +"""Target command — set and show the active platform.""" + +from __future__ import annotations + +import typer +from rich.console import Console + +from whet.adapters.detect import detect_platform +from whet.cli import app +from whet.core.config import PLATFORM_PATHS, Platform + +console = Console() + + +@app.command() +def target( + platform: Platform | None = typer.Argument(None, help="Platform to target."), # noqa: UP007 +) -> None: + """Set or show the target platform. + + Without arguments, shows the current target and auto-detected platform. + """ + if platform: + paths = PLATFORM_PATHS[platform] + console.print(f"[bold green]✓ Target set to {platform.value}[/bold green]") + console.print(f" Global: {paths.global_dir}") + console.print(f" Local: {paths.local_dir}") + return + + # Show current state + detected = detect_platform() + console.print("[bold]Platform Detection[/bold]\n") + + if detected: + console.print(f" Auto-detected: [cyan]{detected.value}[/cyan]") + else: + console.print(" Auto-detected: [dim]none[/dim]") + + console.print("\n[bold]Available Platforms[/bold]\n") + for plat, paths in PLATFORM_PATHS.items(): + marker = " [green]✓[/green]" if plat == detected else "" + console.print(f" [cyan]{plat.value}[/cyan]{marker}") + console.print(f" Global: {paths.global_dir}") + console.print(f" Local: {paths.local_dir}") diff --git a/src/whet/cli/update.py b/src/whet/cli/update.py new file mode 100644 index 0000000..6705c5b --- /dev/null +++ b/src/whet/cli/update.py @@ -0,0 +1,43 @@ +"""Update command — update whet to the latest version.""" + +from __future__ import annotations + +import subprocess +import sys + +from rich.console import Console + +from whet import __version__ +from whet.cli import app + +console = Console() + + +@app.command() +def update() -> None: + """Update whet to the latest version.""" + console.print(f"[bold]Current version:[/bold] v{__version__}\n") + console.print("Checking for updates...") + + try: + result = subprocess.run( # noqa: S603 + [sys.executable, "-m", "pip", "install", "--upgrade", "whet"], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode == 0: + if "already satisfied" in result.stdout.lower(): + console.print("[green]✓ Already up to date.[/green]") + else: + console.print("[bold green]✓ Updated successfully.[/bold green]") + console.print("[dim]Restart your shell to use the new version.[/dim]") + else: + console.print("[yellow]Could not update via pip. Try:[/yellow]") + console.print(" [bold]uv tool upgrade whet[/bold]") + console.print(" or") + console.print(" [bold]uvx --upgrade whet list[/bold]") + except FileNotFoundError: + console.print("[yellow]pip not found. Try:[/yellow]") + console.print(" [bold]uv tool upgrade whet[/bold]") diff --git a/src/whet/core/__init__.py b/src/whet/core/__init__.py new file mode 100644 index 0000000..ea453a2 --- /dev/null +++ b/src/whet/core/__init__.py @@ -0,0 +1,3 @@ +"""Core domain models for whet.""" + +from __future__ import annotations diff --git a/src/whet/core/config.py b/src/whet/core/config.py new file mode 100644 index 0000000..c711356 --- /dev/null +++ b/src/whet/core/config.py @@ -0,0 +1,59 @@ +"""Global and local configuration model.""" + +from __future__ import annotations + +from enum import Enum +from pathlib import Path + +from pydantic import BaseModel, Field + + +class Platform(str, Enum): + """Supported AI coding agent platforms.""" + + CLAUDE = "claude" + ANTIGRAVITY = "antigravity" + CURSOR = "cursor" + COPILOT = "copilot" + + +class PlatformPaths(BaseModel): + """Platform-specific paths for skill installation.""" + + global_dir: Path + local_dir: Path + + +PLATFORM_PATHS: dict[Platform, PlatformPaths] = { + Platform.CLAUDE: PlatformPaths( + global_dir=Path.home() / ".claude" / "skills", + local_dir=Path(".claude") / "skills", + ), + Platform.ANTIGRAVITY: PlatformPaths( + global_dir=Path.home() / ".gemini" / "antigravity" / "skills", + local_dir=Path(".agent") / "skills", + ), + Platform.CURSOR: PlatformPaths( + global_dir=Path.home() / ".cursor" / "rules", + local_dir=Path(".cursor") / "rules", + ), + Platform.COPILOT: PlatformPaths( + global_dir=Path.home() / ".github", + local_dir=Path(".github"), + ), +} + + +class WhetConfig(BaseModel): + """Whet configuration persisted to disk.""" + + target: Platform = Platform.CLAUDE + skills_dir: Path = Field(default_factory=lambda: Path(__file__).resolve().parents[3] / "skills") + agents_dir: Path = Field(default_factory=lambda: Path(__file__).resolve().parents[3] / "agents") + archetypes_dir: Path = Field( + default_factory=lambda: Path(__file__).resolve().parents[3] / "archetypes" + ) + + def get_platform_paths(self) -> PlatformPaths: + """Get paths for the current target platform.""" + return PLATFORM_PATHS[self.target] diff --git a/src/whet/core/skill.py b/src/whet/core/skill.py new file mode 100644 index 0000000..3eed8fc --- /dev/null +++ b/src/whet/core/skill.py @@ -0,0 +1,175 @@ +"""Skill metadata model.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from pydantic import BaseModel, Field + +if sys.version_info >= (3, 12): + import tomllib +else: + import tomli as tomllib + + +class SkillDependencies(BaseModel): + """Skill dependency specification.""" + + requires: list[str] = Field(default_factory=list) + recommends: list[str] = Field(default_factory=list) + + +class SkillCompatibility(BaseModel): + """Skill compatibility specification.""" + + python: str = ">=3.11" + libraries: dict[str, str] = Field(default_factory=dict) + + +class SkillMetadata(BaseModel): + """Machine-readable skill metadata from skill.toml.""" + + name: str + version: str = "1.0.0" + category: str = "core" + tags: list[str] = Field(default_factory=list) + + dependencies: SkillDependencies = Field(default_factory=SkillDependencies) + compatibility: SkillCompatibility = Field(default_factory=SkillCompatibility) + + +class SkillFrontmatter(BaseModel): + """YAML frontmatter parsed from SKILL.md.""" + + name: str + description: str = "" + + +class Skill(BaseModel): + """Complete skill representation combining SKILL.md and skill.toml data.""" + + name: str + description: str = "" + category: str = "core" + tags: list[str] = Field(default_factory=list) + path: Path + metadata: SkillMetadata | None = None + + @property + def skill_md_path(self) -> Path: + """Path to the SKILL.md file.""" + return self.path / "SKILL.md" + + @property + def skill_toml_path(self) -> Path: + """Path to the skill.toml file.""" + return self.path / "skill.toml" + + @property + def has_toml(self) -> bool: + """Whether this skill has a skill.toml metadata file.""" + return self.skill_toml_path.exists() + + def read_skill_md(self) -> str: + """Read the full SKILL.md content.""" + return self.skill_md_path.read_text() + + @classmethod + def from_directory(cls, path: Path) -> Skill: + """Load a skill from its directory.""" + name = path.name + description = "" + category = "core" + tags: list[str] = [] + metadata: SkillMetadata | None = None + + # Parse YAML frontmatter from SKILL.md + skill_md = path / "SKILL.md" + if skill_md.exists(): + content = skill_md.read_text() + frontmatter = _parse_frontmatter(content) + if frontmatter: + name = frontmatter.get("name", name) + description = frontmatter.get("description", "") + + # Parse skill.toml if present + skill_toml = path / "skill.toml" + if skill_toml.exists(): + with open(skill_toml, "rb") as f: + raw = tomllib.load(f) + skill_data = raw.get("skill", {}) + deps_data = raw.get("dependencies", {}) + compat_data = raw.get("compatibility", {}) + + metadata = SkillMetadata( + name=skill_data.get("name", name), + version=skill_data.get("version", "1.0.0"), + category=skill_data.get("category", "core"), + tags=skill_data.get("tags", []), + dependencies=SkillDependencies(**deps_data), + compatibility=SkillCompatibility(**compat_data), + ) + category = metadata.category + tags = metadata.tags + + return cls( + name=name, + description=description.strip() if isinstance(description, str) else "", + category=category, + tags=tags, + path=path, + metadata=metadata, + ) + + +def _parse_frontmatter(content: str) -> dict[str, str] | None: + """Parse YAML frontmatter from a markdown file. + + Returns None if no frontmatter is found. + """ + if not content.startswith("---"): + return None + + lines = content.split("\n") + end_idx = -1 + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + + if end_idx == -1: + return None + + # Simple YAML parsing for flat key-value pairs + result: dict[str, str] = {} + current_key = "" + current_value = "" + + for line in lines[1:end_idx]: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + + if ":" in stripped and not stripped.startswith(" ") and not stripped.startswith("-"): + # Save previous key-value + if current_key: + result[current_key] = current_value.strip() + + key, _, value = stripped.partition(":") + current_key = key.strip() + value = value.strip() + # Handle YAML block scalar indicator + current_value = "" if value == ">" else value + elif current_key and stripped: + # Continuation line for block scalar + if current_value: + current_value += " " + stripped + else: + current_value = stripped + + # Save last key-value + if current_key: + result[current_key] = current_value.strip() + + return result diff --git a/src/whet/py.typed b/src/whet/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/whet/registry/__init__.py b/src/whet/registry/__init__.py new file mode 100644 index 0000000..422a707 --- /dev/null +++ b/src/whet/registry/__init__.py @@ -0,0 +1,3 @@ +"""Skill registry — discovery, loading, and search.""" + +from __future__ import annotations diff --git a/src/whet/registry/loader.py b/src/whet/registry/loader.py new file mode 100644 index 0000000..0d04832 --- /dev/null +++ b/src/whet/registry/loader.py @@ -0,0 +1,46 @@ +"""Scan and parse skills and agents from disk.""" + +from __future__ import annotations + +from pathlib import Path + +from whet.core.skill import Skill + + +def discover_skills(skills_dir: Path) -> list[Skill]: + """Discover all skills in a directory. + + Scans for subdirectories containing SKILL.md and loads each as a Skill. + """ + return _discover(skills_dir) + + +def discover_agents(agents_dir: Path) -> list[Skill]: + """Discover all agents in a directory. + + Agents use the same SKILL.md format as skills. + """ + return _discover(agents_dir) + + +def _discover(base_dir: Path) -> list[Skill]: + """Discover all SKILL.md-based items in a directory.""" + if not base_dir.is_dir(): + return [] + + items: list[Skill] = [] + for child in sorted(base_dir.iterdir()): + if child.is_dir() and not child.name.startswith("."): + skill_md = child / "SKILL.md" + if skill_md.exists(): + items.append(Skill.from_directory(child)) + + return items + + +def load_skill(skills_dir: Path, name: str) -> Skill | None: + """Load a single skill by name.""" + skill_path = skills_dir / name + if skill_path.is_dir() and (skill_path / "SKILL.md").exists(): + return Skill.from_directory(skill_path) + return None diff --git a/src/whet/registry/resolver.py b/src/whet/registry/resolver.py new file mode 100644 index 0000000..833cc36 --- /dev/null +++ b/src/whet/registry/resolver.py @@ -0,0 +1,36 @@ +"""Dependency resolution for skills.""" + +from __future__ import annotations + +from whet.core.skill import Skill + + +def resolve_dependencies( + requested: list[str], + available: dict[str, Skill], +) -> list[str]: + """Resolve skill dependencies, returning an ordered install list. + + Given a list of requested skill names, adds any required dependencies + and returns a topologically-sorted list. + """ + resolved: list[str] = [] + seen: set[str] = set() + + def _visit(name: str) -> None: + if name in seen: + return + seen.add(name) + + skill = available.get(name) + if skill and skill.metadata: + for dep in skill.metadata.dependencies.requires: + if dep in available: + _visit(dep) + + resolved.append(name) + + for name in requested: + _visit(name) + + return resolved diff --git a/src/whet/registry/search.py b/src/whet/registry/search.py new file mode 100644 index 0000000..719df16 --- /dev/null +++ b/src/whet/registry/search.py @@ -0,0 +1,36 @@ +"""Search and filter skills.""" + +from __future__ import annotations + +from whet.core.skill import Skill + + +def search_skills( + skills: list[Skill], + query: str | None = None, + category: str | None = None, + tag: str | None = None, +) -> list[Skill]: + """Search skills by query, category, or tag. + + Query matches against name, description, and tags. + """ + results = skills + + if category: + results = [s for s in results if s.category == category] + + if tag: + results = [s for s in results if tag in s.tags] + + if query: + q = query.lower() + results = [ + s + for s in results + if q in s.name.lower() + or q in s.description.lower() + or any(q in t.lower() for t in s.tags) + ] + + return results diff --git a/src/whet/scaffold/__init__.py b/src/whet/scaffold/__init__.py new file mode 100644 index 0000000..d46f2af --- /dev/null +++ b/src/whet/scaffold/__init__.py @@ -0,0 +1 @@ +"""Project scaffolding engine for whet archetypes.""" diff --git a/src/whet/scaffold/engine.py b/src/whet/scaffold/engine.py new file mode 100644 index 0000000..45561fc --- /dev/null +++ b/src/whet/scaffold/engine.py @@ -0,0 +1,245 @@ +"""Template rendering engine for project scaffolding.""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path +from string import Template + +from pydantic import BaseModel, Field + +if sys.version_info >= (3, 12): + import tomllib +else: + import tomli as tomllib + + +class ArchetypeMetadata(BaseModel, frozen=True): + """Archetype metadata from archetype.toml.""" + + name: str + version: str = "1.0.0" + category: str = "core" + tags: list[str] = Field(default_factory=list) + description: str = "" + + +class ArchetypeSkills(BaseModel, frozen=True): + """Skills associated with an archetype.""" + + required: list[str] = Field(default_factory=list) + recommended: list[str] = Field(default_factory=list) + + +class Archetype(BaseModel, frozen=True): + """Complete archetype representation.""" + + metadata: ArchetypeMetadata + skills: ArchetypeSkills = Field(default_factory=ArchetypeSkills) + path: Path + template_dir: Path + + @property + def has_template(self) -> bool: + """Check if the archetype has template files.""" + if not self.template_dir.is_dir(): + return False + return any(self.template_dir.iterdir()) + + +def load_archetype(archetype_dir: Path) -> Archetype | None: + """Load an archetype from its directory.""" + toml_path = archetype_dir / "archetype.toml" + if not toml_path.exists(): + return None + + with open(toml_path, "rb") as f: + raw = tomllib.load(f) + + arch_data = raw.get("archetype", {}) + skills_data = raw.get("skills", {}) + + metadata = ArchetypeMetadata( + name=arch_data.get("name", archetype_dir.name), + version=arch_data.get("version", "1.0.0"), + category=arch_data.get("category", "core"), + tags=arch_data.get("tags", []), + description=arch_data.get("description", ""), + ) + + skills = ArchetypeSkills( + required=skills_data.get("required", []), + recommended=skills_data.get("recommended", []), + ) + + return Archetype( + metadata=metadata, + skills=skills, + path=archetype_dir, + template_dir=archetype_dir / "template", + ) + + +def discover_archetypes(archetypes_dir: Path) -> list[Archetype]: + """Discover all archetypes in the archetypes directory.""" + if not archetypes_dir.is_dir(): + return [] + + archetypes = [] + for d in sorted(archetypes_dir.iterdir()): + if d.is_dir(): + arch = load_archetype(d) + if arch: + archetypes.append(arch) + + return archetypes + + +class ScaffoldContext(BaseModel): + """Variables available during template rendering.""" + + project_name: str + project_slug: str = "" + package_name: str = "" + description: str = "" + author: str = "" + python_version: str = "3.11" + + def model_post_init(self, __context: object) -> None: + """Derive slug and package name from project name.""" + if not self.project_slug: + self.project_slug = self.project_name.lower().replace(" ", "-").replace("_", "-") + if not self.package_name: + self.package_name = self.project_slug.replace("-", "_") + + +def render_template( + archetype: Archetype, + output_dir: Path, + context: ScaffoldContext, +) -> Path: + """Render an archetype template to the output directory. + + Copies files from the archetype's template directory, performing + variable substitution on file contents and directory/file names. + """ + if not archetype.has_template: + # No template files — create a minimal project structure + return _create_minimal_project(output_dir, context) + + output_dir.mkdir(parents=True, exist_ok=True) + substitutions = context.model_dump() + + for src_path in archetype.template_dir.rglob("*"): + if src_path.is_dir(): + continue + + # Compute relative path with variable substitution in names + rel = src_path.relative_to(archetype.template_dir) + dest_rel = Path(Template(str(rel)).safe_substitute(substitutions)) + dest_path = output_dir / dest_rel + dest_path.parent.mkdir(parents=True, exist_ok=True) + + # Substitute variables in text files + if _is_text_file(src_path): + content = src_path.read_text() + rendered = Template(content).safe_substitute(substitutions) + dest_path.write_text(rendered) + else: + shutil.copy2(src_path, dest_path) + + return output_dir + + +def _create_minimal_project(output_dir: Path, context: ScaffoldContext) -> Path: + """Create a minimal project when no template exists.""" + output_dir.mkdir(parents=True, exist_ok=True) + + # Create src layout + pkg_dir = output_dir / "src" / context.package_name + pkg_dir.mkdir(parents=True, exist_ok=True) + (pkg_dir / "__init__.py").write_text(f'"""{context.project_name}."""\n') + + # Create tests + tests_dir = output_dir / "tests" + tests_dir.mkdir(exist_ok=True) + (tests_dir / "__init__.py").write_text("") + + # Create pyproject.toml + pyproject = f"""[project] +name = "{context.project_slug}" +version = "0.1.0" +description = "{context.description}" +authors = [{{name = "{context.author}"}}] +requires-python = ">={context.python_version}" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/{context.package_name}"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "UP", "S", "B", "A", "C4", "T20", "SIM"] +ignore = ["S101"] + +[tool.mypy] +python_version = "3.11" +strict = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +""" + (output_dir / "pyproject.toml").write_text(pyproject) + + # Create README + readme = f"""# {context.project_name} + +{context.description} + +## Setup + +```bash +uv sync +``` + +## Development + +```bash +uv run pytest tests/ -v +uv run ruff check . +uv run mypy src/ --strict +``` +""" + (output_dir / "README.md").write_text(readme) + + return output_dir + + +def _is_text_file(path: Path) -> bool: + """Check if a file is likely text (for template substitution).""" + text_extensions = { + ".py", + ".md", + ".txt", + ".toml", + ".yaml", + ".yml", + ".json", + ".cfg", + ".ini", + ".sh", + ".bash", + ".dockerfile", + ".gitignore", + ".env", + } + if path.suffix.lower() in text_extensions: + return True + return path.name.lower() in {"dockerfile", "makefile", "justfile", ".gitignore", ".env"} diff --git a/src/whet/settings/__init__.py b/src/whet/settings/__init__.py new file mode 100644 index 0000000..a3e0d6b --- /dev/null +++ b/src/whet/settings/__init__.py @@ -0,0 +1 @@ +"""Settings engine for generating and applying platform-specific settings.""" diff --git a/src/whet/settings/engine.py b/src/whet/settings/engine.py new file mode 100644 index 0000000..48fec29 --- /dev/null +++ b/src/whet/settings/engine.py @@ -0,0 +1,136 @@ +"""Settings merge and diff engine.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + + +class SettingsTemplate(BaseModel, frozen=True): + """A settings template with permissions.""" + + permissions: dict[str, list[str]] = Field(default_factory=dict) + + +def load_template(template_path: Path) -> SettingsTemplate: + """Load a settings template from a JSON file.""" + raw = json.loads(template_path.read_text()) + return SettingsTemplate(**raw) + + +def load_existing_raw(settings_path: Path) -> dict[str, Any] | None: + """Load existing settings file as raw dict, or None if not found.""" + if not settings_path.exists(): + return None + return json.loads(settings_path.read_text()) # type: ignore[no-any-return] + + +def load_existing(settings_path: Path) -> SettingsTemplate | None: + """Load existing settings file as SettingsTemplate, or None if not found. + + Only extracts the permissions.allow list; ignores other keys. + """ + raw = load_existing_raw(settings_path) + if raw is None: + return None + perms = raw.get("permissions", {}) + allow = perms.get("allow", []) + if not isinstance(allow, list): + allow = [] + return SettingsTemplate(permissions={"allow": allow}) + + +def merge_settings( + existing: SettingsTemplate, + template: SettingsTemplate, +) -> SettingsTemplate: + """Merge template permissions into existing settings. + + - Keeps all existing allow entries + - Appends new entries from template + - Deduplicates the result + """ + existing_allow = existing.permissions.get("allow", []) + template_allow = template.permissions.get("allow", []) + + merged = list(dict.fromkeys(existing_allow + template_allow)) + + return SettingsTemplate(permissions={"allow": merged}) + + +def diff_settings( + existing: SettingsTemplate | None, + template: SettingsTemplate, +) -> tuple[list[str], list[str], list[str]]: + """Compute the diff between existing and template settings. + + Returns (new_entries, existing_entries, all_merged). + """ + existing_allow = set(existing.permissions.get("allow", [])) if existing else set() + template_allow = set(template.permissions.get("allow", [])) + + new_entries = sorted(template_allow - existing_allow) + kept_entries = sorted(existing_allow) + all_merged = sorted(existing_allow | template_allow) + + return new_entries, kept_entries, all_merged + + +def write_settings(settings_path: Path, template: SettingsTemplate) -> None: + """Write settings to disk, preserving existing non-permission keys.""" + settings_path.parent.mkdir(parents=True, exist_ok=True) + + # Load existing file to preserve keys we don't manage (hooks, statusLine, etc.) + existing_raw: dict[str, Any] = {} + if settings_path.exists(): + existing_raw = json.loads(settings_path.read_text()) + + # Merge: keep all existing keys, update permissions.allow + existing_perms = existing_raw.get("permissions", {}) + if isinstance(existing_perms, dict): + existing_perms["allow"] = template.permissions.get("allow", []) + else: + existing_perms = template.permissions + + existing_raw["permissions"] = existing_perms + + content = json.dumps(existing_raw, indent=2) + settings_path.write_text(content + "\n") + + +def get_template_path(platform: str) -> Path: + """Get the path to a platform's settings template.""" + templates_dir = Path(__file__).resolve().parents[3] / "settings" + template_file = templates_dir / f"{platform}.json" + if template_file.exists(): + return template_file + # Fall back to base template + return templates_dir / "base.json" + + +def get_settings_target(platform: str, scope: str) -> Path: + """Get the target path for settings based on platform and scope.""" + from whet.core.config import PLATFORM_PATHS, Platform + + plat = Platform(platform) + paths = PLATFORM_PATHS[plat] + + if scope == "global": + if plat == Platform.CLAUDE: + return Path.home() / ".claude" / "settings.json" + if plat == Platform.ANTIGRAVITY: + return Path.home() / ".gemini" / "settings.json" + if plat == Platform.CURSOR: + return Path.home() / ".cursor" / "settings.json" + return paths.global_dir / "settings.json" + else: + if plat == Platform.CLAUDE: + return Path(".claude") / "settings.local.json" + if plat == Platform.ANTIGRAVITY: + return Path(".agent") / "settings.json" + if plat == Platform.CURSOR: + return Path(".cursor") / "settings.json" + return paths.local_dir / "settings.json" diff --git a/tests/test_agents.py b/tests/test_agents.py index 034f8c8..da683b2 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -2,12 +2,18 @@ from __future__ import annotations +import sys from pathlib import Path import pytest AGENTS_DIR = Path("agents") +if sys.version_info >= (3, 12): + import tomllib +else: + import tomli as tomllib + def get_all_agents() -> list[Path]: """Get all agent directories.""" @@ -29,20 +35,28 @@ def test_agent_has_readme(agent_dir: Path) -> None: assert readme.exists(), f"Missing README.md in {agent_dir.name}" -def test_blocking_agents_have_actions() -> None: - """Test that blocking agents have GitHub Actions.""" - blocking_agents = ["code-review", "test-engineer"] - - for agent_name in blocking_agents: - agent_dir = AGENTS_DIR / agent_name - action_yml = agent_dir / "action.yml" - assert action_yml.exists(), f"Missing action.yml for blocking agent {agent_name}" - +@pytest.mark.parametrize("agent_dir", get_all_agents(), ids=lambda d: d.name) +def test_agent_has_toml(agent_dir: Path) -> None: + """Test agent has agent.toml metadata file.""" + toml_path = agent_dir / "agent.toml" + assert toml_path.exists(), f"Missing agent.toml in {agent_dir.name}" -def test_all_expected_agents_exist() -> None: - """Test all expected agents exist.""" - expected = {"expert-coder", "ml-engineer", "code-review", "test-engineer"} - actual = {d.name for d in get_all_agents()} - missing = expected - actual - assert not missing, f"Missing agents: {missing}" +def test_blocking_agents_have_actions() -> None: + """Test that blocking agents have GitHub Actions.""" + for agent_dir in get_all_agents(): + toml_path = agent_dir / "agent.toml" + if toml_path.exists(): + with open(toml_path, "rb") as f: + data = tomllib.load(f) + if data.get("agent", {}).get("type") == "blocking": + action_yml = agent_dir / "action.yml" + assert action_yml.exists(), ( + f"Missing action.yml for blocking agent {agent_dir.name}" + ) + + +def test_minimum_agent_count() -> None: + """Test that we have at least the expected number of agents.""" + agents = get_all_agents() + assert len(agents) >= 4, f"Expected at least 4 agents, found {len(agents)}" diff --git a/tests/test_archetypes.py b/tests/test_archetypes.py index d38b2de..f6555b9 100644 --- a/tests/test_archetypes.py +++ b/tests/test_archetypes.py @@ -26,10 +26,16 @@ def test_archetype_has_readme(archetype_dir: Path) -> None: assert len(content) > 500, f"README too short in {archetype_dir.name}" +@pytest.mark.parametrize("archetype_dir", get_all_archetypes(), ids=lambda d: d.name) +def test_archetype_has_toml(archetype_dir: Path) -> None: + """Test archetype has archetype.toml metadata file.""" + toml_path = archetype_dir / "archetype.toml" + assert toml_path.exists(), f"Missing archetype.toml in {archetype_dir.name}" + + @pytest.mark.parametrize("archetype_dir", get_all_archetypes(), ids=lambda d: d.name) def test_archetype_has_structure(archetype_dir: Path) -> None: """Test archetype has expected structure.""" - # Should have template directory or structure documentation has_template = (archetype_dir / "template").exists() readme = archetype_dir / "README.md" has_structure_doc = "```" in readme.read_text() if readme.exists() else False @@ -37,18 +43,7 @@ def test_archetype_has_structure(archetype_dir: Path) -> None: assert has_template or has_structure_doc, f"Archetype {archetype_dir.name} missing structure" -def test_all_expected_archetypes_exist() -> None: - """Test all expected archetypes exist.""" - expected = { - "pytorch-training-project", - "cv-inference-service", - "research-notebook", - "library-package", - "data-processing-pipeline", - "model-zoo", - } - - actual = {d.name for d in get_all_archetypes()} - - missing = expected - actual - assert not missing, f"Missing archetypes: {missing}" +def test_minimum_archetype_count() -> None: + """Test that we have at least the expected number of archetypes.""" + archetypes = get_all_archetypes() + assert len(archetypes) >= 6, f"Expected at least 6 archetypes, found {len(archetypes)}" diff --git a/tests/test_skills_completeness.py b/tests/test_skills_completeness.py index d896507..1c1953a 100644 --- a/tests/test_skills_completeness.py +++ b/tests/test_skills_completeness.py @@ -30,7 +30,7 @@ def test_skill_md_has_content(skill_dir: Path) -> None: skill_md = skill_dir / "SKILL.md" content = skill_md.read_text() - # Check minimum length (1000+ chars for substantial content) + # Check minimum length assert len(content) > 500, f"SKILL.md in {skill_dir.name} is too short ({len(content)} chars)" # Check has code examples @@ -40,49 +40,50 @@ def test_skill_md_has_content(skill_dir: Path) -> None: assert "# " in content, f"SKILL.md in {skill_dir.name} missing headers" +@pytest.mark.parametrize("skill_dir", get_all_skills(), ids=lambda d: d.name) +def test_skill_md_has_frontmatter(skill_dir: Path) -> None: + """Test that SKILL.md has YAML frontmatter with name and description.""" + skill_md = skill_dir / "SKILL.md" + content = skill_md.read_text() + + assert content.startswith("---"), f"SKILL.md in {skill_dir.name} missing YAML frontmatter" + + # Find closing delimiter + lines = content.split("\n") + end_idx = -1 + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + assert end_idx > 0, f"SKILL.md in {skill_dir.name} has unclosed frontmatter" + + frontmatter = "\n".join(lines[1:end_idx]) + assert "name:" in frontmatter, f"SKILL.md in {skill_dir.name} frontmatter missing 'name'" + assert "description:" in frontmatter, ( + f"SKILL.md in {skill_dir.name} frontmatter missing 'description'" + ) + + +@pytest.mark.parametrize("skill_dir", get_all_skills(), ids=lambda d: d.name) +def test_skill_has_toml(skill_dir: Path) -> None: + """Test that skill has a skill.toml metadata file.""" + toml_path = skill_dir / "skill.toml" + assert toml_path.exists(), f"Missing skill.toml in {skill_dir.name}" + + @pytest.mark.parametrize("skill_dir", get_all_skills(), ids=lambda d: d.name) def test_readme_explains_skill(skill_dir: Path) -> None: """Test that README explains the skill.""" readme = skill_dir / "README.md" content = readme.read_text().lower() - # Should explain purpose assert any(word in content for word in ["purpose", "what", "when", "how", "overview", "use"]), ( f"README in {skill_dir.name} doesn't explain purpose" ) -def test_all_expected_skills_exist() -> None: - """Test that all expected skills are present.""" - expected = { - "master-skill", - "pytorch-lightning", - "pydantic-strict", - "code-quality", - "pixi", - "docker-cv", - "hydra-config", - "loguru", - "testing", - "opencv", - "matplotlib", - "pypi", - "gcp", - "github-actions", - "vscode", - "pre-commit", - "wandb", - "mlflow", - "tensorboard", - "dvc", - "onnx", - "tensorrt", - "abstraction-patterns", - "library-review", - "github-repo-setup", - } - - actual = {d.name for d in get_all_skills()} - - missing = expected - actual - assert not missing, f"Missing skills: {missing}" +def test_minimum_skill_count() -> None: + """Test that we have at least the expected number of skills.""" + skills = get_all_skills() + # Dynamic: just verify we haven't lost skills + assert len(skills) >= 25, f"Expected at least 25 skills, found {len(skills)}" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7fb774a --- /dev/null +++ b/uv.lock @@ -0,0 +1,1158 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "coverage" +version = "7.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" }, + { url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" }, + { url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" }, + { url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" }, + { url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, + { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, + { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, + { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, + { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, + { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + +[[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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/fa/9124cd63d822e2bcbea1450ae68cdc3faf3655c69b455f3a7ed36ce6c628/mkdocs_autorefs-1.4.3.tar.gz", hash = "sha256:beee715b254455c4aa93b6ef3c67579c399ca092259cc41b7d9342573ff1fc75", size = 55425, upload-time = "2025-08-26T14:23:17.223Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4d/7123b6fa2278000688ebd338e2a06d16870aaf9eceae6ba047ea05f92df1/mkdocs_autorefs-1.4.3-py3-none-any.whl", hash = "sha256:469d85eb3114801d08e9cc55d102b3ba65917a869b893403b8987b601cf55dc9", size = 25034, upload-time = "2025-08-26T14:23:15.906Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/4d/1ca8a9432579184599714aaeb36591414cc3d3bfd9d494f6db540c995ae4/mkdocstrings-1.0.2.tar.gz", hash = "sha256:48edd0ccbcb9e30a3121684e165261a9d6af4d63385fc4f39a54a49ac3b32ea8", size = 101048, upload-time = "2026-01-24T15:57:25.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/32/407a9a5fdd7d8ecb4af8d830b9bcdf47ea68f916869b3f44bac31f081250/mkdocstrings-1.0.2-py3-none-any.whl", hash = "sha256:41897815a8026c3634fe5d51472c3a569f92ded0ad8c7a640550873eea3b6817", size = 35443, upload-time = "2026-01-24T15:57:23.933Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffe" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/75/d30af27a2906f00eb90143470272376d728521997800f5dce5b340ba35bc/mkdocstrings_python-2.0.1.tar.gz", hash = "sha256:843a562221e6a471fefdd4b45cc6c22d2607ccbad632879234fa9692e9cf7732", size = 199345, upload-time = "2025-12-03T14:26:11.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/06/c5f8deba7d2cbdfa7967a716ae801aa9ca5f734b8f54fd473ef77a088dbe/mkdocstrings_python-2.0.1-py3-none-any.whl", hash = "sha256:66ecff45c5f8b71bf174e11d49afc845c2dfc7fc0ab17a86b6b337e0f24d8d90", size = 105055, upload-time = "2025-12-03T14:26:10.184Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[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 = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[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 = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[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 = "pymdown-extensions" +version = "10.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, +] + +[[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-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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 = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + +[[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" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "whet" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "rich" }, + { name = "tomli", marker = "python_full_version < '3.12'" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, +] + +[package.metadata] +requires-dist = [ + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, + { name = "mkdocstrings", marker = "extra == 'docs'", specifier = ">=0.24" }, + { name = "mkdocstrings-python", marker = "extra == 'docs'", specifier = ">=1.11" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" }, + { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, + { name = "tomli", marker = "python_full_version < '3.12'", specifier = ">=2.0" }, + { name = "typer", specifier = ">=0.15" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0" }, +] +provides-extras = ["dev", "docs"] From 4b03f2277beab547f98ab21eb9f2b2887338c8b1 Mon Sep 17 00:00:00 2001 From: "Enrique G. Ortiz" Date: Fri, 24 Jul 2026 09:22:31 -0400 Subject: [PATCH 2/4] chore(release): 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump version to 0.3.0 and add a CHANGELOG. Version note: PyPI already carries 0.2.1 while both main and develop declared 0.1.0, and the repository has no git tags — 0.2.1 was published outside the tag-triggered workflow. Releasing from 0.1.0 would build a package older than what is already live, so this bump goes past it. 0.3.0 rather than 0.2.2 because the release is breaking: the agents concept and the --agents-only/--skills-only flags are removed, two skills are deleted, one is renamed, and whet init changes behaviour. Below 1.0.0, breaking changes ship in a minor bump. 536 tests pass; ruff, format, mypy --strict, and mkdocs --strict clean. uv build produces whet-0.3.0 wheel and sdist. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 93 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/whet/__init__.py | 2 +- 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1ac3e84 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,93 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the +version is below 1.0.0, breaking changes are released in a minor bump. + +## [0.3.0] - 2026-07-24 + +A library-wide audit of all three pillars — skills, agents, and archetypes — measured +against Anthropic's Agent Skills guidance. Always-resident skill context is down 73%, +the agents concept is gone, every archetype generates a project that runs, and the +authoring rules are now enforced in CI rather than by discipline. + +### Removed + +- **The `agents/` directory and the agent concept.** The six "agents" were never Claude + Code subagents: four had no YAML frontmatter (so they installed with their H1 as the + entire description and were effectively untriggerable), all six installed into + `.claude/skills/` rather than `.claude/agents/`, `agent.toml` was never parsed by any + code path, and both "blocking" `action.yml` files ran `pixi` against a repo with no + `pixi.toml`. Roughly 77% of their content duplicated existing skills, often diverging + from them. GSD owns agency; whet owns domain knowledge, and knowledge is a skill. +- **`whet install --agents-only` and `--skills-only` flags**, along with + `discover_agents()` and the `agents_dir` config field. +- **Skills `dvc` and `cv-model-selection`.** Guidance that referenced DVC as a generic + concept was rewritten tool-neutral rather than left pointing at a deleted skill. + +### Added + +- **`model-evaluation`** — detection mAP/IoU via `supervision`, confusion matrices, + per-class and per-size breakdowns, deployment-threshold selection, per-slice failure + analysis, and eval-as-CI regression gates. +- **`pydantic-ai`** — typed LLM/VLM structured outputs and VLM-in-the-loop auto-labeling. +- **`data-pipelines`** — storage-format selection, group-aware leakage-preventing dataset + splitting, schema evolution, and data-quality validation. +- **`whet install --prune`** — removes skills that no longer exist upstream. Scoped by a + `.whet-manifest.json` recording what whet installed, so skills placed in the same + directory by other tools are never touched. +- **`whet install --include-extras`** and a `tier` field in `skill.toml`. Skills outside + the flagship path are opt-in: `aws-sagemaker`, `github-repo-setup`, `gradio`, + `huggingface`, `kubernetes`, `mlflow`, `vscode`. +- **`whet init --with-recommended` / `--no-skills`.** +- **Progressive disclosure** across the library: 29 of 32 skills are now a thin index + plus a `references/` directory loaded on demand — 155 reference files in total. +- **CI enforcement.** A new `archetypes` job renders every archetype and runs the + toolchain against the generated project, plus guards for skill-authoring rules + (line ceiling, reference-link integrity, orphaned references, one-level-deep + references, tables of contents, trigger-first and third-person descriptions, + listing budget) and for archetype composition and documented directory trees. + +### Changed + +- **`whet init` now installs the archetype's skills** (with their `references/`) instead + of printing a `whet add ...` hint. An archetype's value over a folder copy is the skill + set it composes, so leaving that as homework made `[skills]` advisory. +- **Skill `pydantic-strict` renamed to `pydantic`.** +- **All skill descriptions rewritten** as third-person, trigger-first "use this skill + when…" statements with explicit disambiguation between adjacent skills. They were topic + summaries, which under-trigger. +- **Every archetype template now generates a project that runs.** Previously four of six + generated only a four-file stub, and the two with templates produced projects that could + not import their own entry point, crashed on the first training step, or shipped no ONNX + despite being named for it. Template files went from 19 to 125. +- **pixi is the canonical environment manager** for generated projects; manifests migrated + from the deprecated `[project]` table to `[workspace]`. +- **Archetype skill composition** now reflects what each template actually ships — every + archetype requires `pixi` and `code-quality`, and `cv-inference-service` finally + requires `fastapi`. + +### Fixed + +- Template files under a `data/` path were silently excluded by the repository's own + `.gitignore`, so a clean checkout produced projects missing `data/raw`, `data/processed`, + and an entire Hydra `data` config group. +- `.ipynb` files were not treated as text by the scaffold engine, so `${package_name}` + inside a notebook was never substituted. +- Adapters now install a skill's `references/` directory (Claude, Antigravity) or inline + it (Cursor, Copilot), so deep-dive links resolve on every platform. +- Documented directory trees in archetype READMEs and docs pages are regenerated from the + templates; 78 documented-but-absent files were removed. + +## [0.2.1] - 2026-07-23 + +Published to PyPI outside this repository's tag-based release workflow; no corresponding +git tag exists. Recorded here for continuity. + +## [0.1.0] - 2026-02-09 + +Initial whet CLI implementation. + +[0.3.0]: https://github.com/ortizeg/whet/releases/tag/v0.3.0 diff --git a/pyproject.toml b/pyproject.toml index 0a92fcc..2b23f82 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "whet" -version = "0.1.0" +version = "0.3.0" description = "Sharpen your AI coder — install expert skills into AI coding agents" authors = [{name = "Enrique G. Ortiz", email = "ortizeg@gmail.com"}] readme = "README.md" diff --git a/src/whet/__init__.py b/src/whet/__init__.py index 2cb7ff7..4731432 100644 --- a/src/whet/__init__.py +++ b/src/whet/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.1.0" +__version__ = "0.3.0" From feab86663e3aac91a206d82f85496c9651246bf7 Mon Sep 17 00:00:00 2001 From: "Enrique G. Ortiz" Date: Fri, 24 Jul 2026 09:25:42 -0400 Subject: [PATCH 3/4] fix(packaging): ship skills, archetypes, and settings in the wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by installing the built wheel into a clean venv and running the CLI outside any checkout, which is what a user actually does. `whet list` printed "No skills found" and `whet doctor` reported a missing skills directory. Two causes: - [tool.hatch.build.targets.wheel] packaged only src/whet. The bundled content was listed for the sdist but never the wheel, so the published artifact was the CLI with none of the product: 36 files, zero SKILL.md. - skills_dir, archetypes_dir, and the settings templates resolved via Path(__file__).parents[3], which is the repository root in a checkout but points outside site-packages once installed. Fixes: - Force-include skills/, archetypes/, and settings/ into the wheel under whet/_data/. The wheel goes from 36 files to 430 — 32 SKILL.md, 155 reference files, 125 archetype template files. - Add whet.core.paths.bundled_dir(), which prefers the packaged copy and falls back to the repo layout so a source checkout still works with no build step. - Drop the stale agents/ entry from the sdist include list. Verified end to end: installing the wheel in a clean venv and running outside the repo now lists 32 skills, passes doctor, and `whet init cv-inference-service` scaffolds a project with 8 skills and 43 reference files installed. Adds tests/test_packaging.py so this cannot ship broken again, including a check that packaging never references a directory that no longer exists. 541 tests pass; ruff, format, mypy --strict, mkdocs --strict clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 ++++ pyproject.toml | 8 ++++- src/whet/core/config.py | 8 ++--- src/whet/core/paths.py | 31 +++++++++++++++++ src/whet/settings/engine.py | 4 ++- tests/test_packaging.py | 69 +++++++++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 src/whet/core/paths.py create mode 100644 tests/test_packaging.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ac3e84..a17c945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,12 @@ authoring rules are now enforced in CI rather than by discipline. ### Fixed +- **The published wheel shipped no skills.** `[tool.hatch.build.targets.wheel]` packaged + only `src/whet`, and the bundled directories were resolved relative to the repository + root — so `uv tool install whet` produced a CLI that reported "No skills found" outside + a source checkout. The wheel now force-includes `skills/`, `archetypes/`, and + `settings/` under `whet/_data/`, and a single resolver prefers the packaged copy while + falling back to the repo layout for development. - Template files under a `data/` path were silently excluded by the repository's own `.gitignore`, so a clean checkout produced projects missing `data/raw`, `data/processed`, and an entire Hydra `data` config group. diff --git a/pyproject.toml b/pyproject.toml index 2b23f82..35b055c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,11 +57,17 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["src/whet"] +# The skills, archetypes, and settings ARE the product; without this the wheel +# ships only the CLI and `whet list` reports "No skills found". +[tool.hatch.build.targets.wheel.force-include] +"skills" = "whet/_data/skills" +"archetypes" = "whet/_data/archetypes" +"settings" = "whet/_data/settings" + [tool.hatch.build.targets.sdist] include = [ "src/whet/", "skills/", - "agents/", "archetypes/", "settings/", ] diff --git a/src/whet/core/config.py b/src/whet/core/config.py index 5cb2bac..66bf88e 100644 --- a/src/whet/core/config.py +++ b/src/whet/core/config.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, Field +from whet.core.paths import bundled_dir + CONFIG_PATH = Path.home() / ".config" / "whet" / "config.json" @@ -51,10 +53,8 @@ class WhetConfig(BaseModel): """Whet configuration persisted to disk.""" target: Platform = Platform.CLAUDE - skills_dir: Path = Field(default_factory=lambda: Path(__file__).resolve().parents[3] / "skills") - archetypes_dir: Path = Field( - default_factory=lambda: Path(__file__).resolve().parents[3] / "archetypes" - ) + skills_dir: Path = Field(default_factory=lambda: bundled_dir("skills")) + archetypes_dir: Path = Field(default_factory=lambda: bundled_dir("archetypes")) @classmethod def load(cls) -> WhetConfig: diff --git a/src/whet/core/paths.py b/src/whet/core/paths.py new file mode 100644 index 0000000..7d74c2c --- /dev/null +++ b/src/whet/core/paths.py @@ -0,0 +1,31 @@ +"""Locate whet's bundled content (skills, archetypes, settings). + +These directories live at the repository root during development, but must also be +found when whet is installed as a wheel — where the package lands in site-packages +and the repository layout no longer exists above it. The build force-includes them +under `whet/_data/`, so resolution prefers that and falls back to the repo layout. +""" + +from __future__ import annotations + +from pathlib import Path + +# .../src/whet/core/paths.py -> parents[1] is the `whet` package. +_PACKAGE_ROOT = Path(__file__).resolve().parents[1] +# .../src/whet/core/paths.py -> parents[3] is the repository root in a source checkout. +_REPO_ROOT = Path(__file__).resolve().parents[3] + +_BUNDLED = _PACKAGE_ROOT / "_data" + + +def bundled_dir(name: str) -> Path: + """Path to a bundled content directory such as "skills" or "archetypes". + + Prefers the packaged copy so an installed wheel works outside any checkout; + falls back to the repository root so a source checkout keeps working with no + build step. + """ + packaged = _BUNDLED / name + if packaged.is_dir(): + return packaged + return _REPO_ROOT / name diff --git a/src/whet/settings/engine.py b/src/whet/settings/engine.py index 48fec29..6ab23c0 100644 --- a/src/whet/settings/engine.py +++ b/src/whet/settings/engine.py @@ -8,6 +8,8 @@ from pydantic import BaseModel, Field +from whet.core.paths import bundled_dir + class SettingsTemplate(BaseModel, frozen=True): """A settings template with permissions.""" @@ -103,7 +105,7 @@ def write_settings(settings_path: Path, template: SettingsTemplate) -> None: def get_template_path(platform: str) -> Path: """Get the path to a platform's settings template.""" - templates_dir = Path(__file__).resolve().parents[3] / "settings" + templates_dir = bundled_dir("settings") template_file = templates_dir / f"{platform}.json" if template_file.exists(): return template_file diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..2cbae27 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,69 @@ +"""Guard that the built wheel actually ships the product. + +whet's value is the skills, archetypes, and settings — not the CLI that copies them. +The wheel once packaged only `src/whet`, so `uv tool install whet` produced a CLI +that reported "No skills found" outside a source checkout. These tests assert the +bundled content is both declared for packaging and reachable at runtime. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if sys.version_info >= (3, 12): + import tomllib +else: + import tomli as tomllib + +from whet.core.paths import bundled_dir + +BUNDLED = ("skills", "archetypes", "settings") + + +def test_wheel_force_includes_bundled_content() -> None: + """Bundled dirs live outside src/whet, so the wheel needs an explicit include.""" + with open("pyproject.toml", "rb") as f: + raw = tomllib.load(f) + + include = raw["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"] + missing = [name for name in BUNDLED if name not in include] + assert not missing, ( + f"wheel does not force-include {missing}; an installed whet would find no skills" + ) + for name in BUNDLED: + assert include[name] == f"whet/_data/{name}", ( + f"{name} must map to whet/_data/{name} so bundled_dir() resolves it" + ) + + +def test_sdist_includes_bundled_content() -> None: + with open("pyproject.toml", "rb") as f: + raw = tomllib.load(f) + + include = raw["tool"]["hatch"]["build"]["targets"]["sdist"]["include"] + missing = [name for name in BUNDLED if f"{name}/" not in include] + assert not missing, f"sdist does not include {missing}" + + +def test_sdist_does_not_ship_removed_directories() -> None: + """agents/ was deleted; packaging must not still reference it.""" + with open("pyproject.toml", "rb") as f: + raw = tomllib.load(f) + + include = raw["tool"]["hatch"]["build"]["targets"]["sdist"]["include"] + stale = [entry for entry in include if not Path(entry.rstrip("/")).exists()] + assert not stale, f"packaging references directories that no longer exist: {stale}" + + +def test_bundled_dir_resolves_in_a_source_checkout() -> None: + """Development must keep working with no build step.""" + for name in BUNDLED: + resolved = bundled_dir(name) + assert resolved.is_dir(), f"bundled_dir({name!r}) -> {resolved} does not exist" + + +def test_bundled_skills_are_the_real_library() -> None: + """Catch a resolver that finds an empty or wrong directory.""" + skills = list(bundled_dir("skills").glob("*/SKILL.md")) + assert len(skills) >= 25, f"bundled skills dir looks wrong: {len(skills)} SKILL.md files" From d2ae0cb995d42c3dcccc3697490ccfe60366b5fd Mon Sep 17 00:00:00 2001 From: "Enrique G. Ortiz" Date: Fri, 24 Jul 2026 10:06:13 -0400 Subject: [PATCH 4/4] fix(ci): make the required 'docs' check reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branch protection on main requires a status check named 'docs', but nothing could ever produce it: - docs.yml only triggered on push to main, so it never ran on a pull request - its jobs were named build/deploy, so even when it did run it reported 'build' and 'deploy' — never 'docs' The result was circular: merging to main required a check that only a push to main could produce. PR #19 sat with three green checks and a fourth that was not pending but simply absent. - Rename the build job to `docs` so it satisfies the required context. - Add a pull_request trigger for main and develop. - Gate artifact upload and the deploy job to pushes on main, so PRs build the docs without touching GitHub Pages. - Build with --strict on PRs. This is the check that caught the dangling agent-page links rather than letting them ship. - Key concurrency by ref so parallel PR doc builds do not queue behind one another. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/docs.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 384440b..111060c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,20 +1,25 @@ -name: Deploy Documentation +name: Documentation on: push: branches: [main] + pull_request: + branches: [main, develop] permissions: contents: read pages: write id-token: write +# Keyed by ref so concurrent PR doc builds do not serialize behind each other, +# while repeated pushes to the same branch still supersede one another. concurrency: - group: "pages" + group: pages-${{ github.ref }} cancel-in-progress: false jobs: - build: + # Job id is `docs` so it satisfies the required status check of that name. + docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -30,20 +35,24 @@ jobs: - name: Install dependencies run: uv sync --extra docs + # --strict fails on broken internal links, which is how the dangling + # agent-page links were caught rather than shipped. - name: Build docs - run: uv run mkdocs build + run: uv run mkdocs build --strict - name: Upload artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@v3 with: path: ./site deploy: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest - needs: build + needs: docs steps: - name: Deploy to GitHub Pages id: deployment