diff --git a/.claude/.gitignore b/.claude/.gitignore new file mode 100644 index 00000000..3e2120c9 --- /dev/null +++ b/.claude/.gitignore @@ -0,0 +1,19 @@ +file-history +debug +downloads +ide +paste-cache +projects +session-env +shell-snapshots +statsig +tasks +todos +user_data +.credentials.json +history.jsonl +hooks/.last_diagnostics_file +hooks/.pre_compact_log + + + diff --git a/src/aipass/py.typed b/.claude/CLAUDE.md similarity index 100% rename from src/aipass/py.typed rename to .claude/CLAUDE.md diff --git a/.claude/commands/memo.md b/.claude/commands/memo.md new file mode 100644 index 00000000..213fbcdb --- /dev/null +++ b/.claude/commands/memo.md @@ -0,0 +1,22 @@ +# Memory Update + +Purpose: Update branch memory files after completing work this session. + +## Execution + +1. Review what was done this session (context, recent changes, key decisions) +2. Update each file below as needed +3. Confirm completion — list files updated + +## What to Update + +### Always + +- **.trinity/local.json** — Update `today_focus` with current work items. Move completed items to `recently_completed`. Add new session entry to `sessions` if significant work was done. +- **.trinity/observations.json** — Add notable collaboration insights: breakthrough moments, pattern corrections, flow states, friction points, preference discoveries. Skip if nothing notable this session. + +### If Relevant + +- **.trinity/passport.json** — Evolve identity when the branch's role, capabilities, or principles have genuinely changed. Don't update just to update — but don't leave placeholders forever either. +- **README.md** — Does it reflect current state? Update if stale. +- **flow.local.md** — Drop quick notes on issues, todos, or ideas discovered during the session. diff --git a/.claude/hooks/branch_prompt_loader.py b/.claude/hooks/branch_prompt_loader.py new file mode 100644 index 00000000..88b8ef9f --- /dev/null +++ b/.claude/hooks/branch_prompt_loader.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +""" +Branch Prompt Loader — AIPass Public Repo + +Injects branch-specific prompts based on CWD. When working in a branch +directory, loads .aipass/branch_system_prompt.md and outputs it so the +AI sees branch-specific context. + +Version: 1.0.0 +""" +from pathlib import Path + + +def find_branch_root() -> Path | None: + """ + Find the branch root directory. + Looks for .trinity/ or .aipass/ as branch indicators. + Stops at the repo root (has pyproject.toml or .git). + """ + cwd = Path.cwd() + search_path = cwd + + while search_path.parent != search_path: + # Branch indicators: has .trinity/ (memory files) or apps/ (code) + has_trinity = (search_path / ".trinity").is_dir() + has_apps = (search_path / "apps").is_dir() + + if has_trinity or has_apps: + return search_path + + # Stop at repo root + if (search_path / "pyproject.toml").exists() or (search_path / ".git").is_dir(): + return None + + search_path = search_path.parent + + return None + + +def main(): + branch_root = find_branch_root() + + if branch_root: + prompt_file = branch_root / ".aipass" / "branch_system_prompt.md" + if prompt_file.exists(): + content = prompt_file.read_text().strip() + branch_name = branch_root.name.upper() + print(f"\n# Branch Context: {branch_name}\n\n{content}") + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/notification_sound.py b/.claude/hooks/notification_sound.py new file mode 100644 index 00000000..3492e707 --- /dev/null +++ b/.claude/hooks/notification_sound.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Notification Hook — Plays sound when AI needs permission.""" + +import json +import sys +import subprocess +from pathlib import Path + +SOUNDS_DIR = Path(__file__).parent.parent / "sounds" +SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav" + + +def play_sound() -> None: + if not SOUND_FILE.exists(): + return + try: + subprocess.Popen( + ["aplay", "-q", str(SOUND_FILE)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass + + +def main(): + try: + hook_data = json.loads(sys.stdin.read()) + if hook_data.get("hook_event_name") == "Notification": + play_sound() + except Exception: + pass + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/stop_sound.py b/.claude/hooks/stop_sound.py new file mode 100644 index 00000000..59236ff6 --- /dev/null +++ b/.claude/hooks/stop_sound.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Stop Hook — Plays achievement bell when AI finishes responding.""" + +import json +import sys +import subprocess +from pathlib import Path + +SOUNDS_DIR = Path(__file__).parent.parent / "sounds" +SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav" + + +def play_sound() -> None: + if not SOUND_FILE.exists(): + return + try: + subprocess.Popen( + ["aplay", "-q", str(SOUND_FILE)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass + + +def main(): + try: + hook_data = json.loads(sys.stdin.read()) + if hook_data.get("hook_event_name") == "Stop": + if not hook_data.get("stop_hook_active", False): + play_sound() + except Exception: + pass + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/tool_use_sound.py b/.claude/hooks/tool_use_sound.py new file mode 100644 index 00000000..fe09c5b3 --- /dev/null +++ b/.claude/hooks/tool_use_sound.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Tool Use Hook — Plays key press sound when AI uses tools.""" + +import json +import sys +import subprocess +from pathlib import Path + +SOUNDS_DIR = Path(__file__).parent.parent / "sounds" +SOUND_FILE = SOUNDS_DIR / "mixkit-atm-cash-machine-key-press-2841.wav" + +SOUND_TOOLS = ["Bash", "Edit", "MultiEdit", "Write", "Read", "Grep", "Glob"] + + +def play_sound() -> None: + if not SOUND_FILE.exists(): + return + try: + subprocess.Popen( + ["aplay", "-q", str(SOUND_FILE)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception: + pass + + +def main(): + try: + hook_data = json.loads(sys.stdin.read()) + if hook_data.get("hook_event_name") == "PreToolUse": + if hook_data.get("tool_name", "") in SOUND_TOOLS: + play_sound() + except Exception: + pass + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..3b16d85f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,65 @@ +{ + "permissions": { + "allow": [], + "deny": [ + "Bash(git reset:*)", + "Bash(git rebase:*)", + "Bash(git config:*)", + "Bash(git push --force:*)", + "EnterPlanMode" + ], + "defaultMode": "acceptEdits" + }, + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "cat .aipass/aipass_global_prompt.md 2>/dev/null || true" + } + ] + }, + { + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/branch_prompt_loader.py" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash|Edit|MultiEdit|Write|Read|Grep|Glob|WebSearch|WebFetch|Task", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/tool_use_sound.py" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/stop_sound.py" + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/notification_sound.py" + } + ] + } + ] + }, + "skipDangerousModePermissionPrompt": true +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5660dc9f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,54 @@ +# Git Attributes for AIPass +# Line ending normalization + binary file handling + +# Auto detect text files and perform LF normalization +* text=auto + +# Source code - always use LF +*.py text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf + +# Documentation - always use LF +*.md text eol=lf +*.txt text eol=lf +LICENSE text eol=lf + +# Shell scripts - must use LF (won't work with CRLF) +*.sh text eol=lf + +# Configuration files +.gitattributes text eol=lf +.gitignore text eol=lf +.env text eol=lf + +# Audio files - binary (don't merge or diff) +*.wav binary +*.mp3 binary +*.ogg binary + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.gz binary + +# Python compiled - binary +*.pyc binary +*.pyo binary +*.whl binary + +# Database files +*.db binary +*.sqlite binary +*.sqlite3 binary + +# Better diffs +*.py diff=python +*.md diff=markdown diff --git a/.gitignore b/.gitignore index de6abdd0..f387d03f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,10 @@ site/ .idea/ .vscode/ *.swp + +# AIPass global config (secrets live here) +.aipass/ +!.aipass/aipass_global_prompt.md + +.archive + diff --git a/src/aipass/__init__.py b/src/aipass/__init__.py index 9ca59884..abef2ef6 100644 --- a/src/aipass/__init__.py +++ b/src/aipass/__init__.py @@ -1,16 +1,7 @@ -"""AIPass — Orchestration framework for autonomous AI agent ecosystems. +"""AIPass — Multi-agent orchestration framework. -Modules: - aipass.drone — Command routing & discovery (v1.0.0 — BUILT) - aipass.paths — Root resolution, AIPASS_ROOT (PLACEHOLDER) - aipass.prax — Visibility & monitoring layer (PLACEHOLDER) - aipass.cortex — Branch creation & templates (PLACEHOLDER) - aipass.mail — Inter-branch messaging (PLACEHOLDER) - -Standalone packages: - seedgo — Code standards framework (v1.0.0 — BUILT) - -Architecture reference: vera/projects/framework/architecture_reference.md +pip install aipass +https://github.com/AIOSAI/AIPass """ -__version__ = "1.0.0" +__version__ = "2.0.0" diff --git a/src/aipass/api/__init__.py b/src/aipass/api/__init__.py new file mode 100644 index 00000000..fc41a58c --- /dev/null +++ b/src/aipass/api/__init__.py @@ -0,0 +1,5 @@ +"""aipass.api — LLM access, model routing, and agent spawning. + +Unified interface to language models. Handles API keys, model selection, +rate limiting, and spawning agent processes. +""" diff --git a/src/aipass/api/handlers/__init__.py b/src/aipass/api/handlers/__init__.py new file mode 100644 index 00000000..88f90a33 --- /dev/null +++ b/src/aipass/api/handlers/__init__.py @@ -0,0 +1 @@ +"""api.handlers — API client implementation and rate limiting.""" diff --git a/src/aipass/api/modules/__init__.py b/src/aipass/api/modules/__init__.py new file mode 100644 index 00000000..2e16a73b --- /dev/null +++ b/src/aipass/api/modules/__init__.py @@ -0,0 +1 @@ +"""api.modules — Model routing and client logic.""" diff --git a/src/aipass/api/plugins/__init__.py b/src/aipass/api/plugins/__init__.py new file mode 100644 index 00000000..8216b2ff --- /dev/null +++ b/src/aipass/api/plugins/__init__.py @@ -0,0 +1 @@ +"""api.plugins — Extensible plugin interface.""" diff --git a/src/aipass/assistant/__init__.py b/src/aipass/assistant/__init__.py new file mode 100644 index 00000000..b4f2270f --- /dev/null +++ b/src/aipass/assistant/__init__.py @@ -0,0 +1,5 @@ +"""aipass.assistant — Scheduling, plugins, and external integrations. + +Plugin architecture connecting AIPass to the outside world: +scheduling engine + discoverable plugin interface. +""" diff --git a/src/aipass/assistant/handlers/__init__.py b/src/aipass/assistant/handlers/__init__.py new file mode 100644 index 00000000..329b5fbc --- /dev/null +++ b/src/aipass/assistant/handlers/__init__.py @@ -0,0 +1 @@ +"""assistant.handlers — Scheduling and integration implementation.""" diff --git a/src/aipass/assistant/modules/__init__.py b/src/aipass/assistant/modules/__init__.py new file mode 100644 index 00000000..0ee9c410 --- /dev/null +++ b/src/aipass/assistant/modules/__init__.py @@ -0,0 +1 @@ +"""assistant.modules — Scheduling engine and plugin orchestration.""" diff --git a/src/aipass/assistant/modules/scheduler.py b/src/aipass/assistant/modules/scheduler.py new file mode 100644 index 00000000..628d1b7e --- /dev/null +++ b/src/aipass/assistant/modules/scheduler.py @@ -0,0 +1 @@ +"""Scheduling engine — manages timed actions, intervals, and due checks.""" diff --git a/src/aipass/assistant/plugins/__init__.py b/src/aipass/assistant/plugins/__init__.py new file mode 100644 index 00000000..976010c5 --- /dev/null +++ b/src/aipass/assistant/plugins/__init__.py @@ -0,0 +1,5 @@ +"""assistant.plugins — External integration plugins (auto-discovered). + +Each plugin connects AIPass to an external service. +User-swappable, independently installable. +""" diff --git a/src/aipass/assistant/plugins/calendar.py b/src/aipass/assistant/plugins/calendar.py new file mode 100644 index 00000000..c74b52ec --- /dev/null +++ b/src/aipass/assistant/plugins/calendar.py @@ -0,0 +1 @@ +"""Calendar plugin — Google Calendar integration.""" diff --git a/src/aipass/assistant/plugins/gmail.py b/src/aipass/assistant/plugins/gmail.py new file mode 100644 index 00000000..5020008e --- /dev/null +++ b/src/aipass/assistant/plugins/gmail.py @@ -0,0 +1 @@ +"""Gmail plugin — email integration for external notifications.""" diff --git a/src/aipass/assistant/plugins/telegram.py b/src/aipass/assistant/plugins/telegram.py new file mode 100644 index 00000000..2d2a0258 --- /dev/null +++ b/src/aipass/assistant/plugins/telegram.py @@ -0,0 +1 @@ +"""Telegram plugin — bridge between AIPass and Telegram bots.""" diff --git a/src/aipass/cortex/__init__.py b/src/aipass/cortex/__init__.py deleted file mode 100644 index d442d256..00000000 --- a/src/aipass/cortex/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -"""AIPass Cortex — Branch Creation & Template Engine. - -Creates new branches (agent workspaces) following a 7-step flow: - 1. Validate template — verify template directory exists - 2. Extract metadata — branch name, profile, git repo from path - 3. Build placeholders — replacement dict: {{BRANCHNAME}}, {{CWD}}, {{DATE}}, etc. - 4. Smart file rename — rename existing memory files to standard convention - 5. Copy template — copy entire template, replace placeholders in text files - 6. File renaming — LOCAL.json -> BRANCHNAME.local.json, etc. - 7. Registry entry — add to BRANCH_REGISTRY.json + fire trigger event - -Three template types: - - branch_template/ — standard dev branch (apps/, modules/, handlers/, tests/) - - business_branch_template/ — business branch (playbooks/, strategy/, research/) - - team_template/ — think-tank manager (research/, ideas/, decisions/) - -Accessed through drone: `drone @cortex create`, NOT standalone. - -Architecture reference: vera/projects/framework/architecture_reference.md §5 - -Status: PLACEHOLDER — not yet implemented. -""" diff --git a/src/aipass/devpulse/__init__.py b/src/aipass/devpulse/__init__.py new file mode 100644 index 00000000..7860b35f --- /dev/null +++ b/src/aipass/devpulse/__init__.py @@ -0,0 +1,5 @@ +"""aipass.devpulse — User orchestrator and dashboard. + +Home base for users: system status, project config, dashboard. +The entry point for understanding what's happening across the ecosystem. +""" diff --git a/src/aipass/devpulse/handlers/__init__.py b/src/aipass/devpulse/handlers/__init__.py new file mode 100644 index 00000000..45d0d5f0 --- /dev/null +++ b/src/aipass/devpulse/handlers/__init__.py @@ -0,0 +1 @@ +"""devpulse.handlers — Status aggregation and config management.""" diff --git a/src/aipass/devpulse/modules/__init__.py b/src/aipass/devpulse/modules/__init__.py new file mode 100644 index 00000000..4dcff730 --- /dev/null +++ b/src/aipass/devpulse/modules/__init__.py @@ -0,0 +1 @@ +"""devpulse.modules — Dashboard and status logic.""" diff --git a/src/aipass/devpulse/plugins/__init__.py b/src/aipass/devpulse/plugins/__init__.py new file mode 100644 index 00000000..977718ef --- /dev/null +++ b/src/aipass/devpulse/plugins/__init__.py @@ -0,0 +1 @@ +"""devpulse.plugins — Extensible plugin interface.""" diff --git a/src/aipass/drone/__init__.py b/src/aipass/drone/__init__.py index df1ae23b..b126177d 100644 --- a/src/aipass/drone/__init__.py +++ b/src/aipass/drone/__init__.py @@ -1,97 +1,5 @@ +"""aipass.drone — Command routing, @resolution, module discovery. + +The backbone of AIPass. Routes `drone @module command` to the correct +module, resolves @ targets to paths, discovers installed modules. """ -AIPass Drone — Command routing & discovery module. - -Provides symbolic addressing for multi-agent systems. Resolves @branch names -to absolute paths at runtime. - -Core API: - resolve_branch(name) -> str # @branch → absolute path - register_branch(name, path, type) # Add branch to registry - list_branches(type, status) # List all branches - branch_exists(name) -> bool # Check if branch exists - get_branch_info(name) -> dict # Get branch metadata - -Command Routing (Phase 2): - route_command(target, command, args, timeout) -> CommandResult - discover_modules(target) -> list # Available commands for a branch - get_help(target, command) -> HelpResult # Structured help for a branch - -@all Operations (Phase 3): - route_all(command, args, timeout) -> dict[str, CommandResult] - get_system_help() -> dict[str, HelpResult] # Help across all branches - -Registry Management: - initialize_registry() # Create empty registry - set_registry_path(path) # Set custom registry location - get_registry_path() -> Path # Get current registry path - -Example: - >>> from aipass.drone import resolve_branch, register_branch - >>> register_branch("my_agent", "/path/to/agent", "agent") - >>> path = resolve_branch("@my_agent") - >>> print(path) - /path/to/agent - - >>> from aipass.drone import route_command - >>> result = route_command("@my_agent", "status") - >>> print(result.stdout) - - >>> from aipass.drone import route_all - >>> results = route_all("status") - >>> for branch, r in results.items(): - ... print(f"{branch}: exit={r.exit_code}") -""" - -from .config import get_registry_path, reset_registry_path, set_registry_path -from .discovery import HelpResult, discover_modules, get_help, get_system_help -from .exceptions import ( - BranchAlreadyExistsError, - BranchNotFoundError, - CommandExecutionError, - InvalidPathError, - RegistryCorruptError, - RegistryError, - RegistryNotFoundError, - RegistryPermissionError, - RoutingError, -) -from .executor import CommandResult -from .registry import add_branch as register_branch -from .registry import initialize_registry -from .resolver import branch_exists, get_branch_info, list_branches, resolve_branch -from .router import route_all, route_command - -__version__ = "1.0.0" - -__all__ = [ - # Core API - "resolve_branch", - "register_branch", - "list_branches", - "branch_exists", - "get_branch_info", - # Command routing (Phase 2) - "route_command", - "discover_modules", - "get_help", - "CommandResult", - # Help & discovery (Phase 3) - "HelpResult", - "get_system_help", - "route_all", - # Registry management - "initialize_registry", - "set_registry_path", - "get_registry_path", - "reset_registry_path", - # Exceptions - "RoutingError", - "BranchNotFoundError", - "BranchAlreadyExistsError", - "InvalidPathError", - "RegistryError", - "RegistryNotFoundError", - "RegistryCorruptError", - "RegistryPermissionError", - "CommandExecutionError", -] diff --git a/src/aipass/drone/cli.py b/src/aipass/drone/cli.py deleted file mode 100644 index 7ffd38a8..00000000 --- a/src/aipass/drone/cli.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Drone CLI — command-line interface for aipass.drone. - -Entry point: `drone` (wired via pyproject.toml console_scripts to cli.main). - -Usage: - drone Show available commands - drone --help Show help - drone --version Show version - drone systems List registered branches and modules - drone @branch command [args] Route command to branch - drone @module command [args] Route command to internal module - drone @branch --help Show help for branch or module - -Zero external dependencies — stdlib only (sys). -""" - -from __future__ import annotations - -import sys - -import aipass -from aipass.drone import ( - CommandExecutionError, - BranchNotFoundError, - get_help, - list_branches, - route_command, -) -from aipass.drone.modules import ( - is_module, - list_modules, - get_module_info, - get_module_introspective, - route_module_command, - get_module_help, -) - -_HELP_TEXT = """\ -drone — AIPass command router - -Usage: - drone Show this help - drone --help Show this help - drone --version Show version - drone systems List registered branches and modules - drone @target command [args] Route command to branch or module - drone @target --help Show help for branch or module - -Examples: - drone systems - drone @seedgo audit src/ - drone @seedgo check src/myfile.py - drone @flow status - drone @flow --help -""" - - -def main() -> None: - """Entry point for the `drone` CLI command. - - Parses sys.argv manually and dispatches to the appropriate handler. - Exits with the command's exit code on branch commands, 0 on success, - 1 on error. - """ - args = sys.argv[1:] - - # No args or explicit --help - if not args or args == ["--help"]: - print(_HELP_TEXT, end="") - sys.exit(0) - - # --version - if args == ["--version"]: - print(aipass.__version__) - sys.exit(0) - - # systems — list registered branches - if args[0] == "systems": - _cmd_systems() - return - - # @branch ... — route to a branch - if args[0].startswith("@"): - _cmd_branch(args) - return - - # Unknown command - print(f"drone: unknown command '{args[0]}'", file=sys.stderr) - print("Run 'drone --help' for usage.", file=sys.stderr) - sys.exit(1) - - -def _cmd_systems() -> None: - """Handle `drone systems` — list registered branches and modules.""" - branches = list_branches() - modules = list_modules() - - if not branches and not modules: - print("No branches or modules registered.") - sys.exit(0) - - if modules: - print(f"Modules ({len(modules)}):") - for name in modules: - info = get_module_info(name) - if info: - print(f" @{name} {info.description}") - else: - print(f" @{name} (not available)") - if branches: - print() - - if branches: - print(f"Branches ({len(branches)}):") - for name in sorted(branches): - print(f" {name}") - - sys.exit(0) - - -def _cmd_branch(args: list[str]) -> None: - """Handle `drone @target command [args]` or `drone @target --help`. - - Checks internal modules first (e.g. @seedgo), then falls back - to branch registry for external branches. - """ - target = args[0] - rest = args[1:] - module_name = target.lstrip("@") - - # Check if this is a registered internal module - if is_module(module_name): - _cmd_module(module_name, rest) - return - - # Fall through to branch routing - # drone @branch --help - if not rest or rest == ["--help"]: - try: - result = get_help(target) - if result.text: - print(result.text, end="") - else: - print(f"No help available for {target}.") - except BranchNotFoundError as exc: - print(f"drone: {exc}", file=sys.stderr) - sys.exit(1) - except CommandExecutionError as exc: - print(f"drone: {exc}", file=sys.stderr) - sys.exit(1) - sys.exit(0) - - # drone @branch command [args...] - command = rest[0] - cmd_args = rest[1:] - - try: - result = route_command(target, command, args=cmd_args if cmd_args else None) - except BranchNotFoundError as exc: - print(f"drone: {exc}", file=sys.stderr) - sys.exit(1) - except CommandExecutionError as exc: - print(f"drone: {exc}", file=sys.stderr) - sys.exit(1) - - if result.stdout: - print(result.stdout, end="") - if result.stderr: - print(result.stderr, end="", file=sys.stderr) - sys.exit(result.exit_code) - - -def _cmd_module(name: str, args: list[str]) -> None: - """Handle routing to an internal module (e.g. @seedgo). - - No args → introspective (discovery: what's connected). - --help → help text (usage documentation). - """ - # drone @module (no args) → introspective / discovery - if not args: - intro_text = get_module_introspective(name) - if intro_text: - print(intro_text, end="") - else: - print(f"No information available for @{name}.") - sys.exit(0) - - # drone @module --help → usage documentation - if args == ["--help"]: - help_text = get_module_help(name) - if help_text: - print(help_text, end="") - else: - print(f"No help available for @{name}.") - sys.exit(0) - - command = args[0] - cmd_args = args[1:] if len(args) > 1 else None - - try: - result = route_module_command(name, command, cmd_args) - except (ImportError, AttributeError) as exc: - print(f"drone: module @{name} is registered but not available: {exc}", file=sys.stderr) - sys.exit(1) - - if result.get("stdout"): - print(result["stdout"], end="") - if result.get("stderr"): - print(result["stderr"], end="", file=sys.stderr) - sys.exit(result.get("exit_code", 0)) diff --git a/src/aipass/drone/config.py b/src/aipass/drone/config.py deleted file mode 100644 index 36292a5b..00000000 --- a/src/aipass/drone/config.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Registry configuration management. - -Handles registry path configuration with environment variable support. -""" - -import os -from pathlib import Path -from typing import Optional - - -_registry_path: Optional[Path] = None - - -def get_registry_path() -> Path: - """ - Get the current registry path. - - Priority: - 1. Explicitly set path via set_registry_path() - 2. AIPASS_REGISTRY_PATH environment variable - 3. Default: ~/.aipass/BRANCH_REGISTRY.json - - Returns: - Path to the registry file - """ - global _registry_path - - if _registry_path is not None: - return _registry_path - - env_path = os.environ.get("AIPASS_REGISTRY_PATH") - if env_path: - return Path(env_path) - - return Path.home() / ".aipass" / "BRANCH_REGISTRY.json" - - -def set_registry_path(path: str | Path) -> None: - """ - Set a custom registry path. - - Args: - path: Path to the registry file - """ - global _registry_path - _registry_path = Path(path) - - -def reset_registry_path() -> None: - """ - Reset registry path to default (useful for testing). - """ - global _registry_path - _registry_path = None diff --git a/src/aipass/drone/discovery.py b/src/aipass/drone/discovery.py deleted file mode 100644 index ec592d0b..00000000 --- a/src/aipass/drone/discovery.py +++ /dev/null @@ -1,278 +0,0 @@ -""" -Module and command discovery for AIPass branch introspection. - -Introspects branch capabilities by querying entry points for help text -and scanning module directories as a fallback. -""" - -import logging -import subprocess -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional - -from .exceptions import CommandExecutionError -from .resolver import list_branches, resolve_branch - -logger = logging.getLogger(__name__) - - -@dataclass -class HelpResult: - """ - Structured result from a help query. - - Attributes: - branch: The branch name the help was retrieved from. - command: The specific command queried, or None for top-level help. - text: Raw help text (stdout or stderr from the entry point). - commands_found: List of command names parsed from the help text. - """ - - branch: str - command: Optional[str] - text: str - commands_found: List[str] = field(default_factory=list) - - -def _get_entry_point(branch_path: str, branch_name: str) -> Optional[Path]: - """ - Return the apps/{branch_name}.py entry point path if it exists, else None. - - Args: - branch_path: Absolute path to the branch directory. - branch_name: Lowercase branch name. - - Returns: - Path to the entry point, or None if it does not exist. - """ - entry_point = Path(branch_path) / "apps" / f"{branch_name}.py" - return entry_point if entry_point.exists() else None - - -def _scan_modules_directory(branch_path: str) -> List[str]: - """ - Scan apps/modules/ for .py files and return their stems as command names. - - Used as a fallback when the entry point's --help output cannot be parsed. - - Args: - branch_path: Absolute path to the branch directory. - - Returns: - Sorted list of module stem names (without .py extension), excluding - __init__ and __main__. - """ - modules_dir = Path(branch_path) / "apps" / "modules" - if not modules_dir.is_dir(): - return [] - - excluded = {"__init__", "__main__"} - return sorted( - f.stem - for f in modules_dir.glob("*.py") - if f.stem not in excluded - ) - - -def _parse_help_for_commands(help_text: str) -> List[str]: - """ - Parse --help output to extract a list of available commands. - - Handles multiple CLI framework formats: - - Section-based (custom CLIs, argparse subparsers): looks for headers - labelled "commands", "subcommands", or "available commands". - - Click-style: looks for indented entries under "Commands:" or "Options:". - - Positional args listing: lines matching ``{command}`` or ``[command]`` - at the start of usage lines. - - Falls back to returning an empty list if no recognisable pattern is found. - - Args: - help_text: Raw stdout from ``python3 entry.py --help``. - - Returns: - List of command names parsed from the help text. - """ - commands: List[str] = [] - in_commands_section = False - - section_markers = {"commands", "subcommands", "available commands"} - - for line in help_text.splitlines(): - stripped = line.strip() - - # Detect section header lines (e.g. "Commands:", "Available commands:") - if any(marker in stripped.lower() for marker in section_markers): - in_commands_section = True - continue - - # A blank line ends the current section. - if in_commands_section and not stripped: - in_commands_section = False - continue - - if in_commands_section: - # Indented lines that start with a word are command entries. - if line.startswith((" ", "\t")) and stripped: - # Take the first token as the command name. - token = stripped.split()[0] - # Skip lines that look like option flags. - if not token.startswith("-"): - commands.append(token) - - return commands - - -def discover_modules(target: str) -> List[str]: - """ - Discover available commands for a branch. - - Resolution order: - 1. Resolve @target to its absolute path via the registry. - 2. Attempt to run ``python3 apps/{name}.py --help`` and parse the output - for a commands section. - 3. If parsing yields nothing, fall back to scanning ``apps/modules/`` for - .py file stems. - - Args: - target: Symbolic branch name with or without @ prefix (e.g., "@flow"). - - Returns: - List of discovered command/module names (may be empty). - - Raises: - BranchNotFoundError: If the target branch is not in the registry. - """ - # Step 1: resolve — BranchNotFoundError propagates to caller. - branch_path = resolve_branch(target) - branch_name = target.lstrip("@").lower() - - # Step 2: try --help on the entry point. - entry_point = _get_entry_point(branch_path, branch_name) - if entry_point is not None: - try: - result = subprocess.run( - ["python3", str(entry_point.relative_to(branch_path)), "--help"], - cwd=branch_path, - capture_output=True, - timeout=10, - shell=False, - ) - help_text = result.stdout.decode("utf-8", errors="replace") - if not help_text: - help_text = result.stderr.decode("utf-8", errors="replace") - - commands = _parse_help_for_commands(help_text) - if commands: - return commands - except (subprocess.TimeoutExpired, OSError): - pass - - # Step 3: fallback — scan modules directory. - return _scan_modules_directory(branch_path) - - -def get_help(target: str, command: Optional[str] = None) -> HelpResult: - """ - Get structured help for a branch or a specific command. - - Executes: - python3 apps/{name}.py --help (when command is None) - python3 apps/{name}.py {command} --help (when command is given) - - Args: - target: Symbolic branch name with or without @ prefix (e.g., "@flow"). - command: Specific command to get help for (default: None). - - Returns: - HelpResult with branch, command, text, and commands_found populated. - - Raises: - BranchNotFoundError: If the target branch is not in the registry. - CommandExecutionError: If the entry point does not exist or execution - fails. - """ - branch_path = resolve_branch(target) - branch_name = target.lstrip("@").lower() - - entry_point = _get_entry_point(branch_path, branch_name) - if entry_point is None: - raise CommandExecutionError( - f"Entry point not found for branch '{branch_name}': " - f"{Path(branch_path) / 'apps' / (branch_name + '.py')}" - ) - - relative_entry = str(entry_point.relative_to(branch_path)) - if command is None: - cmd_args = [relative_entry, "--help"] - else: - cmd_args = [relative_entry, command, "--help"] - - try: - result = subprocess.run( - ["python3"] + cmd_args, - cwd=branch_path, - capture_output=True, - timeout=10, - shell=False, - ) - except subprocess.TimeoutExpired as e: - raise CommandExecutionError( - f"Help command timed out for branch '{branch_name}'" - ) from e - except OSError as e: - raise CommandExecutionError( - f"OS error getting help for branch '{branch_name}': {e}" - ) from e - - stdout = result.stdout.decode("utf-8", errors="replace") - stderr = result.stderr.decode("utf-8", errors="replace") - - # Many CLIs write help to stderr; return whichever is non-empty. - text = stdout if stdout.strip() else stderr - commands_found = _parse_help_for_commands(text) - - return HelpResult( - branch=branch_name, - command=command, - text=text, - commands_found=commands_found, - ) - - -def get_system_help() -> Dict[str, HelpResult]: - """ - Aggregate help across all active branches in the registry. - - Iterates over every active branch and attempts to retrieve top-level help - via ``get_help()``. Branches whose entry point is missing or whose help - command fails are silently skipped (the error is logged at DEBUG level). - - Returns: - Mapping of branch name (without @ prefix) to its HelpResult. - Branches that cannot be queried are omitted from the result. - - Example: - >>> results = get_system_help() - >>> for branch, help_result in results.items(): - ... print(f"{branch}: {help_result.commands_found}") - """ - results: Dict[str, HelpResult] = {} - - # list_branches returns names with @ prefix; iterate all active branches. - active_branches = list_branches(status="active") - - for symbolic_name in active_branches: - branch_name = symbolic_name.lstrip("@") - try: - help_result = get_help(symbolic_name) - results[branch_name] = help_result - except Exception as exc: - logger.debug( - "get_system_help: skipping branch '%s': %s", - branch_name, - exc, - ) - - return results diff --git a/src/aipass/drone/exceptions.py b/src/aipass/drone/exceptions.py deleted file mode 100644 index 020dc621..00000000 --- a/src/aipass/drone/exceptions.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -Drone module custom exceptions. - -Defines the exception hierarchy for routing and branch resolution errors. -""" - - -class RoutingError(Exception): - """Base exception for all routing-related errors.""" - pass - - -class BranchNotFoundError(RoutingError): - """Raised when a branch cannot be found in the registry.""" - pass - - -class BranchAlreadyExistsError(RoutingError): - """Raised when attempting to register a branch that already exists.""" - pass - - -class InvalidPathError(RoutingError): - """Raised when a path is invalid or doesn't exist.""" - pass - - -class RegistryError(RoutingError): - """Base exception for registry-related errors.""" - pass - - -class RegistryNotFoundError(RegistryError): - """Raised when the registry file doesn't exist.""" - pass - - -class RegistryCorruptError(RegistryError): - """Raised when the registry file is corrupted or invalid JSON.""" - pass - - -class RegistryPermissionError(RegistryError): - """Raised when there are permission issues accessing the registry.""" - pass - - -class CommandExecutionError(RoutingError): - """Raised when command execution fails (Phase 2 feature).""" - pass diff --git a/src/aipass/drone/executor.py b/src/aipass/drone/executor.py deleted file mode 100644 index 10568e4f..00000000 --- a/src/aipass/drone/executor.py +++ /dev/null @@ -1,97 +0,0 @@ -""" -Safe subprocess execution for branch command routing. - -Wraps subprocess.run with safety guards: timeout enforcement, no shell injection, -captured output, and consistent error wrapping via CommandExecutionError. - -Defines CommandResult, the shared return type for all routing operations. -""" - -import subprocess -from dataclasses import dataclass -from typing import List - -from .exceptions import CommandExecutionError - - -@dataclass -class CommandResult: - """ - Result of a routed command execution. - - Attributes: - stdout: Captured standard output from the command. - stderr: Captured standard error from the command. - exit_code: Process exit code (0 typically indicates success). - branch: The branch name the command was routed to. - command: The command string that was executed. - """ - - stdout: str - stderr: str - exit_code: int - branch: str - command: str - - -def execute_command( - executable: str, - args: List[str], - cwd: str, - timeout: int = 30, -) -> CommandResult: - """ - Execute a command via subprocess with safety guards. - - Args: - executable: The executable to run (e.g., "python3"). - args: List of arguments (e.g., ["apps/flow.py", "status"]). - cwd: Working directory for command execution (absolute path). - timeout: Maximum execution time in seconds (default: 30). - - Returns: - CommandResult with stdout, stderr, exit_code populated. - branch and command fields are empty strings; route_command populates them. - - Raises: - CommandExecutionError: If the process times out, the executable is not - found, or any other OS-level error prevents execution. - - Notes: - - Never uses shell=True to prevent shell injection attacks. - - Always captures stdout and stderr separately. - - Decodes output as UTF-8, replacing undecodable bytes. - """ - full_cmd = [executable] + list(args) - - try: - result = subprocess.run( - full_cmd, - cwd=cwd, - capture_output=True, - timeout=timeout, - shell=False, - ) - except subprocess.TimeoutExpired as e: - raise CommandExecutionError( - f"Command timed out after {timeout}s: {' '.join(full_cmd)}" - ) from e - except FileNotFoundError as e: - raise CommandExecutionError( - f"Executable not found: {executable!r}" - ) from e - except OSError as e: - raise CommandExecutionError( - f"OS error executing command: {e}" - ) from e - - stdout = result.stdout.decode("utf-8", errors="replace") - stderr = result.stderr.decode("utf-8", errors="replace") - - return CommandResult( - stdout=stdout, - stderr=stderr, - exit_code=result.returncode, - branch="", - command="", - ) diff --git a/src/aipass/drone/handlers/__init__.py b/src/aipass/drone/handlers/__init__.py new file mode 100644 index 00000000..f7fc1e88 --- /dev/null +++ b/src/aipass/drone/handlers/__init__.py @@ -0,0 +1 @@ +"""drone.handlers — Implementation details for routing and dispatch.""" diff --git a/src/aipass/drone/handlers/dispatch.py b/src/aipass/drone/handlers/dispatch.py new file mode 100644 index 00000000..7d1454ed --- /dev/null +++ b/src/aipass/drone/handlers/dispatch.py @@ -0,0 +1 @@ +"""Dispatch handler — executes resolved commands against target modules.""" diff --git a/src/aipass/drone/modules.py b/src/aipass/drone/modules.py deleted file mode 100644 index 27751b39..00000000 --- a/src/aipass/drone/modules.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Internal module registry for drone. - -Routes @module commands to Python packages installed alongside drone, -as opposed to external branches in BRANCH_REGISTRY.json. - -Modules register by providing a drone_adapter module with: -- DRONE_MODULE dict (name, version, description) -- handle_command(command, args) -> dict with stdout/stderr/exit_code -- get_help(command=None) -> str -""" - -from __future__ import annotations - -import importlib -from dataclasses import dataclass - -# Maps module name -> import path for its drone_adapter -_MODULE_REGISTRY: dict[str, str] = { - "seedgo": "seedgo.drone_adapter", - # Future modules register here: - # "prax": "aipass.prax.drone_adapter", - # "cortex": "aipass.cortex.drone_adapter", -} - - -@dataclass -class ModuleInfo: - """Metadata about a registered module.""" - - name: str - version: str - description: str - adapter_path: str - - -def list_modules() -> list[str]: - """Return sorted list of registered module names.""" - return sorted(_MODULE_REGISTRY.keys()) - - -def get_module_info(name: str) -> ModuleInfo | None: - """Get module metadata without executing anything. - - Returns None if the module is not registered or not importable. - """ - adapter_path = _MODULE_REGISTRY.get(name) - if adapter_path is None: - return None - try: - mod = importlib.import_module(adapter_path) - meta = getattr(mod, "DRONE_MODULE", {}) - return ModuleInfo( - name=meta.get("name", name), - version=meta.get("version", "unknown"), - description=meta.get("description", ""), - adapter_path=adapter_path, - ) - except ImportError: - return None - - -def is_module(name: str) -> bool: - """Check if name is a registered module (doesn't verify importability).""" - return name in _MODULE_REGISTRY - - -def route_module_command(name: str, command: str, args: list[str] | None = None) -> dict: - """Route a command to a module's drone adapter. - - Returns dict with keys: stdout, stderr, exit_code. - Raises: KeyError if module not registered, ImportError if adapter missing, - AttributeError if adapter lacks handle_command. - """ - adapter_path = _MODULE_REGISTRY[name] - mod = importlib.import_module(adapter_path) - handler = getattr(mod, "handle_command") - return handler(command, args) - - -def get_module_help(name: str, command: str | None = None) -> str: - """Get help text from a module's drone adapter. - - Returns help string, or empty string if unavailable. - """ - adapter_path = _MODULE_REGISTRY.get(name) - if adapter_path is None: - return "" - try: - mod = importlib.import_module(adapter_path) - help_fn = getattr(mod, "get_help", None) - if help_fn is None: - return "" - return help_fn(command) - except (ImportError, AttributeError): - return "" - - -def get_module_introspective(name: str) -> str: - """Get introspective view from a module's drone adapter. - - Introspective = discovery mode (no args): shows what's connected. - Falls back to help text if the adapter doesn't implement get_introspective(). - - Returns introspective string, or empty string if unavailable. - """ - adapter_path = _MODULE_REGISTRY.get(name) - if adapter_path is None: - return "" - try: - mod = importlib.import_module(adapter_path) - # Try introspective first, fall back to help - intro_fn = getattr(mod, "get_introspective", None) - if intro_fn is not None: - return intro_fn() - # Fallback: use help if no introspective defined - help_fn = getattr(mod, "get_help", None) - if help_fn is not None: - return help_fn(None) - return "" - except (ImportError, AttributeError): - return "" - - -def register_module(name: str, adapter_path: str) -> None: - """Register a new module. Used for dynamic registration (e.g., plugins).""" - _MODULE_REGISTRY[name] = adapter_path diff --git a/src/aipass/drone/modules/__init__.py b/src/aipass/drone/modules/__init__.py new file mode 100644 index 00000000..271e8f0c --- /dev/null +++ b/src/aipass/drone/modules/__init__.py @@ -0,0 +1 @@ +"""drone.modules — Business logic for command routing and resolution.""" diff --git a/src/aipass/drone/modules/resolver.py b/src/aipass/drone/modules/resolver.py new file mode 100644 index 00000000..0ee93172 --- /dev/null +++ b/src/aipass/drone/modules/resolver.py @@ -0,0 +1 @@ +"""@ resolver — maps @targets to module paths.""" diff --git a/src/aipass/drone/modules/router.py b/src/aipass/drone/modules/router.py new file mode 100644 index 00000000..c47cc30a --- /dev/null +++ b/src/aipass/drone/modules/router.py @@ -0,0 +1 @@ +"""Command router — parses `drone @module command` and dispatches.""" diff --git a/src/aipass/drone/plugins/__init__.py b/src/aipass/drone/plugins/__init__.py new file mode 100644 index 00000000..b039af65 --- /dev/null +++ b/src/aipass/drone/plugins/__init__.py @@ -0,0 +1 @@ +"""drone.plugins — Extensible plugin interface.""" diff --git a/src/aipass/drone/registry.py b/src/aipass/drone/registry.py deleted file mode 100644 index 97da56e6..00000000 --- a/src/aipass/drone/registry.py +++ /dev/null @@ -1,260 +0,0 @@ -""" -Registry operations for branch management. - -Handles loading, saving, and managing the BRANCH_REGISTRY.json file. -""" - -import json -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional - -from .config import get_registry_path -from .exceptions import ( - BranchAlreadyExistsError, - InvalidPathError, - RegistryCorruptError, - RegistryNotFoundError, - RegistryPermissionError, -) - - -def load_registry() -> Dict[str, Any]: - """ - Load the branch registry from disk. - - Returns: - Registry dictionary with version, branches, and metadata - - Raises: - RegistryNotFoundError: If registry file doesn't exist - RegistryCorruptError: If registry file is invalid JSON - RegistryPermissionError: If registry file cannot be read - """ - registry_path = get_registry_path() - - if not registry_path.exists(): - raise RegistryNotFoundError( - f"Registry not found at {registry_path}. " - "Run initialize_registry() to create one." - ) - - try: - with open(registry_path, "r", encoding="utf-8") as f: - data = json.load(f) - except PermissionError as e: - raise RegistryPermissionError(f"Permission denied reading registry: {e}") - except json.JSONDecodeError as e: - raise RegistryCorruptError(f"Registry file is corrupted: {e}") - except Exception as e: - raise RegistryCorruptError(f"Failed to read registry: {e}") - - # Validate structure - if not isinstance(data, dict): - raise RegistryCorruptError("Registry must be a JSON object") - - if "branches" not in data: - raise RegistryCorruptError("Registry missing 'branches' field") - - return data - - -def save_registry(registry: Dict[str, Any]) -> None: - """ - Save the branch registry to disk. - - Args: - registry: Registry dictionary to save - - Raises: - RegistryPermissionError: If registry file cannot be written - """ - registry_path = get_registry_path() - - # Ensure directory exists - registry_path.parent.mkdir(parents=True, exist_ok=True) - - # Update metadata timestamp - if "metadata" not in registry: - registry["metadata"] = {} - - registry["metadata"]["last_updated"] = datetime.now(timezone.utc).isoformat() - registry["metadata"]["managed_by"] = "aipass.drone" - - try: - with open(registry_path, "w", encoding="utf-8") as f: - json.dump(registry, f, indent=2) - except PermissionError as e: - raise RegistryPermissionError(f"Permission denied writing registry: {e}") - except Exception as e: - raise RegistryPermissionError(f"Failed to write registry: {e}") - - -def initialize_registry() -> None: - """ - Create a new empty registry file. - - Creates the registry directory and file if they don't exist. - If registry already exists, does nothing. - """ - registry_path = get_registry_path() - - if registry_path.exists(): - return - - registry = { - "version": "1.0", - "branches": {}, - "metadata": { - "last_updated": datetime.now(timezone.utc).isoformat(), - "managed_by": "aipass.drone", - }, - } - - save_registry(registry) - - -def add_branch( - name: str, - path: str | Path, - branch_type: str = "agent", - status: str = "active", -) -> None: - """ - Add a branch to the registry. - - Args: - name: Branch name (without @ prefix) - path: Absolute path to branch directory - branch_type: Type of branch (agent, service, module, etc.) - status: Branch status (active, inactive, archived) - - Raises: - BranchAlreadyExistsError: If branch name already exists - InvalidPathError: If path doesn't exist or isn't a directory - RegistryNotFoundError: If registry doesn't exist - """ - # Validate path - path_obj = Path(path).resolve() - if not path_obj.exists(): - raise InvalidPathError(f"Path does not exist: {path}") - if not path_obj.is_dir(): - raise InvalidPathError(f"Path is not a directory: {path}") - - # Load registry - try: - registry = load_registry() - except RegistryNotFoundError: - # Auto-initialize if registry doesn't exist - initialize_registry() - registry = load_registry() - - # Check if branch already exists - if name in registry["branches"]: - raise BranchAlreadyExistsError( - f"Branch '{name}' already exists in registry" - ) - - # Add branch entry - registry["branches"][name] = { - "name": name, - "path": str(path_obj), - "type": branch_type, - "status": status, - "created": datetime.now(timezone.utc).isoformat(), - } - - save_registry(registry) - - -def remove_branch(name: str) -> None: - """ - Remove a branch from the registry. - - Args: - name: Branch name (without @ prefix) - - Raises: - RegistryNotFoundError: If registry doesn't exist - """ - registry = load_registry() - - if name in registry["branches"]: - del registry["branches"][name] - save_registry(registry) - - -def update_branch_status(name: str, status: str) -> None: - """ - Update the status of a branch. - - Args: - name: Branch name (without @ prefix) - status: New status (active, inactive, archived) - - Raises: - RegistryNotFoundError: If registry doesn't exist - """ - registry = load_registry() - - if name in registry["branches"]: - registry["branches"][name]["status"] = status - registry["branches"][name]["last_seen"] = datetime.now(timezone.utc).isoformat() - save_registry(registry) - - -def get_all_branches( - branch_type: Optional[str] = None, - status: str = "active", -) -> List[Dict[str, Any]]: - """ - Get all branches from the registry, optionally filtered. - - Args: - branch_type: Filter by branch type (optional) - status: Filter by status (default: "active") - - Returns: - List of branch dictionaries - - Raises: - RegistryNotFoundError: If registry doesn't exist - """ - try: - registry = load_registry() - except RegistryNotFoundError: - return [] - - branches = registry.get("branches", {}).values() - - # Apply filters - filtered = [] - for branch in branches: - if status and branch.get("status") != status: - continue - if branch_type and branch.get("type") != branch_type: - continue - filtered.append(branch) - - return filtered - - -def get_branch_by_name(name: str) -> Optional[Dict[str, Any]]: - """ - Get a single branch by name. - - Args: - name: Branch name (without @ prefix) - - Returns: - Branch dictionary or None if not found - - Raises: - RegistryNotFoundError: If registry doesn't exist - """ - try: - registry = load_registry() - except RegistryNotFoundError: - return None - - return registry.get("branches", {}).get(name) diff --git a/src/aipass/drone/resolver.py b/src/aipass/drone/resolver.py deleted file mode 100644 index 34767cee..00000000 --- a/src/aipass/drone/resolver.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Branch resolution logic. - -Resolves symbolic @branch names to absolute paths and metadata. -""" - -from typing import Any, Dict, List, Optional - -from .exceptions import BranchNotFoundError -from .registry import get_all_branches, get_branch_by_name, load_registry - - -def normalize_branch_name(symbolic_name: str) -> str: - """ - Normalize a symbolic branch name. - - Strips @ prefix if present and returns clean branch name. - - Args: - symbolic_name: Branch name with or without @ prefix - - Returns: - Clean branch name without @ prefix - """ - if symbolic_name.startswith("@"): - return symbolic_name[1:] - return symbolic_name - - -def resolve_branch(symbolic_name: str) -> str: - """ - Resolve a symbolic branch name to its absolute path. - - Args: - symbolic_name: Branch name with or without @ prefix (e.g., "@my_agent" or "my_agent") - - Returns: - Absolute path to branch directory as string - - Raises: - BranchNotFoundError: If branch not in registry - RegistryNotFoundError: If registry file missing or corrupt - """ - # Let RegistryNotFoundError propagate from load_registry - registry = load_registry() - - name = normalize_branch_name(symbolic_name) - branch = registry.get("branches", {}).get(name) - - if branch is None: - raise BranchNotFoundError( - f"Branch '{symbolic_name}' not found in registry" - ) - - return branch["path"] - - -def branch_exists(symbolic_name: str) -> bool: - """ - Check if a branch exists in the registry. - - Args: - symbolic_name: Branch name with or without @ prefix - - Returns: - True if branch exists, False otherwise - """ - name = normalize_branch_name(symbolic_name) - branch = get_branch_by_name(name) - return branch is not None - - -def get_branch_info(symbolic_name: str) -> Dict[str, Any]: - """ - Get full metadata for a branch. - - Args: - symbolic_name: Branch name with or without @ prefix - - Returns: - Dictionary with branch metadata (name, path, type, status, timestamps) - - Raises: - BranchNotFoundError: If branch not in registry - RegistryNotFoundError: If registry file missing or corrupt - """ - # Let RegistryNotFoundError propagate from load_registry - registry = load_registry() - - name = normalize_branch_name(symbolic_name) - branch = registry.get("branches", {}).get(name) - - if branch is None: - raise BranchNotFoundError( - f"Branch '{symbolic_name}' not found in registry" - ) - - return branch - - -def list_branches( - branch_type: Optional[str] = None, - status: str = "active", -) -> List[str]: - """ - List all registered branches, optionally filtered by type and status. - - Args: - branch_type: Filter by branch type (optional) - status: Filter by status (default: "active") - - Returns: - List of branch names with @ prefix - """ - branches = get_all_branches(branch_type=branch_type, status=status) - return [f"@{branch['name']}" for branch in branches] diff --git a/src/aipass/drone/router.py b/src/aipass/drone/router.py deleted file mode 100644 index 2fd61dfb..00000000 --- a/src/aipass/drone/router.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -Command routing logic for the AIPass drone module. - -Routes commands to branch entry points by resolving symbolic @branch names, -locating the branch's apps/{name}.py entry point, and executing via subprocess. -""" - -import logging -from pathlib import Path -from typing import Dict, List, Optional - -from .exceptions import CommandExecutionError -from .executor import CommandResult, execute_command -from .resolver import list_branches, resolve_branch - -logger = logging.getLogger(__name__) - - -def _find_entry_point(branch_path: str, branch_name: str) -> Path: - """ - Locate the apps/{branch_name}.py entry point for a branch. - - Args: - branch_path: Absolute path to the branch directory. - branch_name: Lowercase branch name from the registry. - - Returns: - Path to the entry point file. - - Raises: - CommandExecutionError: If the entry point does not exist. - """ - entry_point = Path(branch_path) / "apps" / f"{branch_name}.py" - if not entry_point.exists(): - raise CommandExecutionError( - f"Entry point not found for branch '{branch_name}': {entry_point}" - ) - return entry_point - - -def route_command( - target: str, - command: str, - args: Optional[List[str]] = None, - timeout: int = 30, -) -> CommandResult: - """ - Route a command to a branch's entry point. - - Resolves @target to an absolute path, locates the branch entry point at - {path}/apps/{branch_name}.py, then executes: - python3 apps/{name}.py {command} [args...] - - The process runs with cwd set to the branch root so relative paths inside - the entry point (e.g. imports of sibling modules) resolve correctly. - - Args: - target: Symbolic branch name with or without @ prefix (e.g., "@flow"). - command: Command to pass to the branch entry point (e.g., "status"). - args: Optional list of additional arguments (default: None). - timeout: Maximum execution time in seconds (default: 30). - - Returns: - CommandResult with stdout, stderr, exit_code, branch, and command. - - Raises: - BranchNotFoundError: If the target branch is not in the registry. - CommandExecutionError: If the entry point is missing or execution fails. - """ - if args is None: - args = [] - - # Step 1: Resolve @target → absolute path. BranchNotFoundError propagates. - branch_path = resolve_branch(target) - - # Derive the canonical branch name (strip leading @). - branch_name = target.lstrip("@").lower() - - # Step 2: Locate entry point. - entry_point = _find_entry_point(branch_path, branch_name) - - # Step 3: Build argument list and execute. - # Use a relative path to apps/{name}.py so the subprocess cwd is the branch root. - relative_entry = str(entry_point.relative_to(branch_path)) - cmd_args = [relative_entry, command] + list(args) - - result = execute_command( - executable="python3", - args=cmd_args, - cwd=branch_path, - timeout=timeout, - ) - - # Annotate result with routing context. - return CommandResult( - stdout=result.stdout, - stderr=result.stderr, - exit_code=result.exit_code, - branch=branch_name, - command=command, - ) - - -def route_all( - command: str, - args: Optional[List[str]] = None, - timeout: int = 30, -) -> Dict[str, CommandResult]: - """ - Route the same command to ALL active branches in the registry. - - Iterates over every active branch and calls ``route_command`` on each. - Individual branch failures (missing entry point, execution error) are - logged at WARNING level and recorded as a CommandResult with exit_code=-1 - and the error message in stderr. Processing continues regardless of - per-branch failures. - - Args: - command: Command to pass to every branch entry point (e.g., "status"). - args: Optional list of additional arguments forwarded to each branch. - timeout: Maximum execution time per branch in seconds (default: 30). - - Returns: - Dictionary mapping branch name (without @ prefix) to its CommandResult. - Branches that fail are included with exit_code=-1 and error in stderr. - - Example: - >>> results = route_all("status") - >>> for branch, result in results.items(): - ... print(f"{branch}: exit={result.exit_code}") - """ - if args is None: - args = [] - - results: Dict[str, CommandResult] = {} - - active_branches = list_branches(status="active") - - for symbolic_name in active_branches: - branch_name = symbolic_name.lstrip("@") - try: - result = route_command(symbolic_name, command, args=list(args), timeout=timeout) - results[branch_name] = result - except Exception as exc: - logger.warning( - "route_all: branch '%s' failed for command '%s': %s", - branch_name, - command, - exc, - ) - results[branch_name] = CommandResult( - stdout="", - stderr=str(exc), - exit_code=-1, - branch=branch_name, - command=command, - ) - - return results diff --git a/src/aipass/flow/__init__.py b/src/aipass/flow/__init__.py new file mode 100644 index 00000000..a388d989 --- /dev/null +++ b/src/aipass/flow/__init__.py @@ -0,0 +1,5 @@ +"""aipass.flow — Planning, workflow management, and tracking. + +Owns ALL planning: DPLANs (design), FPLANs (execution), MPLANs (master). +Each branch gets flow.local.md for issues, todos, and scratchpad. +""" diff --git a/src/aipass/flow/handlers/__init__.py b/src/aipass/flow/handlers/__init__.py new file mode 100644 index 00000000..fe7ea7ed --- /dev/null +++ b/src/aipass/flow/handlers/__init__.py @@ -0,0 +1 @@ +"""flow.handlers — Plan file management and template rendering.""" diff --git a/src/aipass/flow/modules/__init__.py b/src/aipass/flow/modules/__init__.py new file mode 100644 index 00000000..53c3a923 --- /dev/null +++ b/src/aipass/flow/modules/__init__.py @@ -0,0 +1 @@ +"""flow.modules — Plan lifecycle and tracking logic.""" diff --git a/src/aipass/flow/plugins/__init__.py b/src/aipass/flow/plugins/__init__.py new file mode 100644 index 00000000..8305dadc --- /dev/null +++ b/src/aipass/flow/plugins/__init__.py @@ -0,0 +1 @@ +"""flow.plugins — Extensible plugin interface.""" diff --git a/src/aipass/mail/__init__.py b/src/aipass/mail/__init__.py index 3c603aee..da9671ab 100644 --- a/src/aipass/mail/__init__.py +++ b/src/aipass/mail/__init__.py @@ -1,21 +1,5 @@ -"""AIPass Mail — Inter-Branch Messaging. +"""aipass.mail — Agent-to-agent messaging and dispatch. -Communication layer for AI agent ecosystems. Branches coordinate through -email, not direct file access. This is how 10+ agents work simultaneously -without stepping on each other. - -Each branch has a mailbox (BRANCHNAME.ai_mail.json). Mail supports: - - send/receive between branches - - dispatch flags (--dispatch marks for autonomous execution) - - inbox/outbox management - - reply chains with auto-close and archival - -Integration requirement: branches register in BRANCH_REGISTRY.json -to be addressable. Seed enforces this. - -Depends on: aipass-paths. - -Architecture reference: vera/projects/framework/architecture_reference.md §8 - -Status: PLACEHOLDER — not yet implemented. +Handles inter-branch communication: send, receive, inbox management, +delivery routing, and dispatch flags for autonomous execution. """ diff --git a/src/aipass/mail/handlers/__init__.py b/src/aipass/mail/handlers/__init__.py new file mode 100644 index 00000000..dad9d246 --- /dev/null +++ b/src/aipass/mail/handlers/__init__.py @@ -0,0 +1 @@ +"""mail.handlers — Delivery and inbox implementation.""" diff --git a/src/aipass/mail/modules/__init__.py b/src/aipass/mail/modules/__init__.py new file mode 100644 index 00000000..94d87daa --- /dev/null +++ b/src/aipass/mail/modules/__init__.py @@ -0,0 +1 @@ +"""mail.modules — Messaging business logic.""" diff --git a/src/aipass/mail/plugins/__init__.py b/src/aipass/mail/plugins/__init__.py new file mode 100644 index 00000000..a91b1f76 --- /dev/null +++ b/src/aipass/mail/plugins/__init__.py @@ -0,0 +1 @@ +"""mail.plugins — Extensible plugin interface.""" diff --git a/src/aipass/memory/__init__.py b/src/aipass/memory/__init__.py new file mode 100644 index 00000000..55f7d115 --- /dev/null +++ b/src/aipass/memory/__init__.py @@ -0,0 +1,7 @@ +"""aipass.memory — Unified memory system. + +Three sub-modules: +- trinity: Memory files (wraps trinity-pattern pip package) +- agents: Agent creation, registry, scaffolding (powers aipass init) +- bank: Vector search on rolled-over archives (ChromaDB) +""" diff --git a/src/aipass/memory/handlers/__init__.py b/src/aipass/memory/handlers/__init__.py new file mode 100644 index 00000000..746e8fef --- /dev/null +++ b/src/aipass/memory/handlers/__init__.py @@ -0,0 +1 @@ +"""memory.handlers — Archive management and storage implementation.""" diff --git a/src/aipass/memory/modules/__init__.py b/src/aipass/memory/modules/__init__.py new file mode 100644 index 00000000..b3971c75 --- /dev/null +++ b/src/aipass/memory/modules/__init__.py @@ -0,0 +1 @@ +"""memory.modules — Trinity files, agent lifecycle, and vector search.""" diff --git a/src/aipass/memory/modules/agents.py b/src/aipass/memory/modules/agents.py new file mode 100644 index 00000000..d34c75f3 --- /dev/null +++ b/src/aipass/memory/modules/agents.py @@ -0,0 +1,7 @@ +"""Agent creation, registry, and scaffolding. + +Powers `aipass init` (builds all citizens on first run) and +`drone @memory create agent "X"` (adds new branches later). + +Creates: passport.json, flow.local.md, birth certificate, registry entry. +""" diff --git a/src/aipass/memory/modules/bank.py b/src/aipass/memory/modules/bank.py new file mode 100644 index 00000000..07dae5cb --- /dev/null +++ b/src/aipass/memory/modules/bank.py @@ -0,0 +1,5 @@ +"""Vector search on rolled-over archives. + +ChromaDB-backed semantic search across archived memory files, +closed plans, and historical session data. +""" diff --git a/src/aipass/memory/modules/trinity.py b/src/aipass/memory/modules/trinity.py new file mode 100644 index 00000000..26cdaae5 --- /dev/null +++ b/src/aipass/memory/modules/trinity.py @@ -0,0 +1,5 @@ +"""Memory file management — wraps and extends trinity-pattern package. + +Handles passport.json, local.json, observations.json per branch. +FIFO rollover to archive when files exceed limits. +""" diff --git a/src/aipass/memory/plugins/__init__.py b/src/aipass/memory/plugins/__init__.py new file mode 100644 index 00000000..92cab191 --- /dev/null +++ b/src/aipass/memory/plugins/__init__.py @@ -0,0 +1 @@ +"""memory.plugins — Extensible plugin interface.""" diff --git a/src/aipass/paths/__init__.py b/src/aipass/paths/__init__.py deleted file mode 100644 index b7a6ee17..00000000 --- a/src/aipass/paths/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -AIPass Paths — Root Resolution Module. - -Foundation package. ZERO external dependencies. - -Resolves the AIPass root directory using a 3-tier priority: - 1. AIPASS_ROOT environment variable (explicit — deployment/CI) - 2. .aipass/ marker directory walk (auto-detect — like git finding .git/) - 3. ~/.aipass/ default fallback (fresh install — XDG pattern) - -All other modules derive paths from get_root(): - - system_logs_dir() -> get_root() / "system_logs" - - branch_registry_path() -> get_root() / "BRANCH_REGISTRY.json" - - branch_path(name) -> registry lookup, returns None if not found - - branch_logs_dir(dir) -> dir / "logs" - -Exceptions: - - PathResolutionError -> raised when root cannot be resolved -""" - -from __future__ import annotations - -from .derived import branch_logs_dir, branch_path, branch_registry_path, system_logs_dir -from .exceptions import PathResolutionError -from .resolver import get_root - -__version__ = "1.0.0" - -__all__ = [ - # Core - "get_root", - # Derived paths - "system_logs_dir", - "branch_registry_path", - "branch_path", - "branch_logs_dir", - # Exceptions - "PathResolutionError", -] diff --git a/src/aipass/paths/derived.py b/src/aipass/paths/derived.py deleted file mode 100644 index 04dc743c..00000000 --- a/src/aipass/paths/derived.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Derived path functions for AIPass. - -All functions derive their paths from get_root() and return Path objects. -No side effects beyond reading the filesystem. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from .resolver import get_root - - -def system_logs_dir() -> Path: - """ - Return the system logs directory. - - Returns: - get_root() / "system_logs" - """ - return get_root() / "system_logs" - - -def branch_registry_path() -> Path: - """ - Return the path to BRANCH_REGISTRY.json. - - Returns: - get_root() / "BRANCH_REGISTRY.json" - """ - return get_root() / "BRANCH_REGISTRY.json" - - -def branch_path(name: str) -> Path | None: - """ - Look up a branch by name in BRANCH_REGISTRY.json and return its path. - - Args: - name: Branch name (without @ prefix). - - Returns: - Absolute Path to the branch directory, or None if the branch is not - found or the registry file does not exist. - """ - registry_file = branch_registry_path() - - if not registry_file.exists(): - return None - - try: - with open(registry_file, encoding="utf-8") as fh: - data = json.load(fh) - except (json.JSONDecodeError, OSError): - return None - - branches = data.get("branches", {}) - entry = branches.get(name) - if entry is None: - return None - - raw_path = entry.get("path") - if raw_path is None: - return None - - return Path(raw_path) - - -def branch_logs_dir(branch_dir: Path) -> Path: - """ - Return the logs directory for a given branch directory. - - Args: - branch_dir: Absolute path to a branch directory. - - Returns: - branch_dir / "logs" - """ - return branch_dir / "logs" diff --git a/src/aipass/paths/exceptions.py b/src/aipass/paths/exceptions.py deleted file mode 100644 index 0022f658..00000000 --- a/src/aipass/paths/exceptions.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Paths module custom exceptions. - -Defines the exception hierarchy for path resolution errors. -""" - -from __future__ import annotations - - -class PathResolutionError(Exception): - """Raised when the AIPass root directory cannot be resolved.""" - - pass diff --git a/src/aipass/paths/resolver.py b/src/aipass/paths/resolver.py deleted file mode 100644 index c5da0705..00000000 --- a/src/aipass/paths/resolver.py +++ /dev/null @@ -1,100 +0,0 @@ -""" -Root resolution logic for AIPass paths. - -Resolves the AIPass root directory using a 3-tier priority: - 1. AIPASS_ROOT environment variable (if set and valid directory) - 2. .aipass/ marker directory walk (like git finding .git/) - 3. ~/.aipass/ default fallback (created if it doesn't exist) -""" - -from __future__ import annotations - -import os -from pathlib import Path - -from .exceptions import PathResolutionError - - -def _resolve_from_env() -> Path | None: - """ - Attempt to resolve root from AIPASS_ROOT environment variable. - - Returns: - Path if env var is set and points to a valid directory, None otherwise. - - Raises: - PathResolutionError: If env var is set but points to a non-existent directory. - """ - env_val = os.environ.get("AIPASS_ROOT") - if not env_val: # None or empty string → not set - return None - - candidate = Path(env_val) - if not candidate.is_dir(): - raise PathResolutionError( - f"AIPASS_ROOT environment variable points to a non-existent directory: {env_val!r}" - ) - return candidate - - -def _resolve_from_marker(start: Path | None = None) -> Path | None: - """ - Walk up the directory tree from start looking for a directory containing .aipass/. - - Args: - start: Directory to start from. Defaults to Path.cwd(). - - Returns: - The directory that contains a .aipass/ subdirectory, or None if not found. - """ - current = (start or Path.cwd()).resolve() - - for candidate in [current, *current.parents]: - if (candidate / ".aipass").is_dir(): - return candidate - - return None - - -def _resolve_default() -> Path: - """ - Return the default fallback root (~/.aipass/), creating it if needed. - - Returns: - Path to ~/.aipass/, guaranteed to exist. - """ - default = Path.home() / ".aipass" - default.mkdir(parents=True, exist_ok=True) - return default - - -def get_root(start: Path | None = None) -> Path: - """ - Resolve the AIPass root directory using 3-tier priority. - - Priority order: - 1. AIPASS_ROOT environment variable (if set and valid) - 2. .aipass/ marker directory walk (starting from CWD or start) - 3. ~/.aipass/ default fallback (created if missing) - - Args: - start: Optional starting directory for marker walk. Defaults to Path.cwd(). - - Returns: - Resolved AIPass root directory as an absolute Path. - - Raises: - PathResolutionError: If AIPASS_ROOT env var is set but points to a non-existent directory. - """ - # Tier 1: environment variable - env_root = _resolve_from_env() - if env_root is not None: - return env_root - - # Tier 2: marker walk - marker_root = _resolve_from_marker(start) - if marker_root is not None: - return marker_root - - # Tier 3: default fallback - return _resolve_default() diff --git a/src/aipass/prax/__init__.py b/src/aipass/prax/__init__.py index 9f01453c..e3d60d42 100644 --- a/src/aipass/prax/__init__.py +++ b/src/aipass/prax/__init__.py @@ -1,24 +1,5 @@ -"""AIPass Prax — Visibility & Monitoring Layer. +"""aipass.prax — Monitoring, events, and observability. -Project-scoped logging, monitoring, and observability for AI agent ecosystems. -Depends on: aipass-paths, watchdog. - -Internal Prax has 10 subsystems. The public version ships with 4 non-negotiable -trust changes: - 1. No global logging.getLogger() override — users call prax.logger() explicitly - 2. Project-scoped only — never operates outside the project directory - 3. All external communication opt-in — zero external by default - 4. Consent layer — prax init / prax status / prax stop (through drone) - -Three modes: - - Public: Safe defaults. Minimal, transparent, project-scoped. - - Full: Opt-in power features (file watcher, log tailing, Telegram relay). - - AIPass: Internal unchanged (not shipped publicly). - -Other modules don't configure Prax — they import correctly and use -warning(), info(), error() methods. Prax manages himself internally. - -Architecture reference: vera/projects/framework/architecture_reference.md §6 - -Status: PLACEHOLDER — not yet implemented. +Real-time system monitoring, event tracking, and logging infrastructure. +The eyes of the ecosystem. """ diff --git a/src/aipass/prax/handlers/__init__.py b/src/aipass/prax/handlers/__init__.py new file mode 100644 index 00000000..a6f31852 --- /dev/null +++ b/src/aipass/prax/handlers/__init__.py @@ -0,0 +1 @@ +"""prax.handlers — Log streaming and alert implementation.""" diff --git a/src/aipass/prax/modules/__init__.py b/src/aipass/prax/modules/__init__.py new file mode 100644 index 00000000..39a53345 --- /dev/null +++ b/src/aipass/prax/modules/__init__.py @@ -0,0 +1 @@ +"""prax.modules — Event processing and monitor logic.""" diff --git a/src/aipass/prax/plugins/__init__.py b/src/aipass/prax/plugins/__init__.py new file mode 100644 index 00000000..5c9862ef --- /dev/null +++ b/src/aipass/prax/plugins/__init__.py @@ -0,0 +1 @@ +"""prax.plugins — Extensible plugin interface.""" diff --git a/src/aipass/seedgo/__init__.py b/src/aipass/seedgo/__init__.py new file mode 100644 index 00000000..9599b65b --- /dev/null +++ b/src/aipass/seedgo/__init__.py @@ -0,0 +1,5 @@ +"""aipass.seedgo — Code standards and quality checks. + +Public standards framework. Plugin-based architecture for automated +code quality auditing across the project. +""" diff --git a/src/aipass/seedgo/handlers/__init__.py b/src/aipass/seedgo/handlers/__init__.py new file mode 100644 index 00000000..f81eb9eb --- /dev/null +++ b/src/aipass/seedgo/handlers/__init__.py @@ -0,0 +1 @@ +"""seedgo.handlers — Check execution and reporting.""" diff --git a/src/aipass/seedgo/modules/__init__.py b/src/aipass/seedgo/modules/__init__.py new file mode 100644 index 00000000..7c8a24a5 --- /dev/null +++ b/src/aipass/seedgo/modules/__init__.py @@ -0,0 +1 @@ +"""seedgo.modules — Standards runner and audit logic.""" diff --git a/src/aipass/seedgo/plugins/__init__.py b/src/aipass/seedgo/plugins/__init__.py new file mode 100644 index 00000000..a32e4c22 --- /dev/null +++ b/src/aipass/seedgo/plugins/__init__.py @@ -0,0 +1 @@ +"""seedgo.plugins — Quality check plugins (auto-discovered).""" diff --git a/src/aipass/skills/__init__.py b/src/aipass/skills/__init__.py new file mode 100644 index 00000000..c384a09e --- /dev/null +++ b/src/aipass/skills/__init__.py @@ -0,0 +1,11 @@ +"""aipass.skills — User-built add-ons that compose infrastructure. + +Skills are self-contained folders that import from AIPass infrastructure +modules (drone, api, prax, mail, etc.) to create new capabilities. + +Infrastructure = the plumbing (modules/handlers/plugins inside each module). +Skills = what users build ON TOP of the plumbing. + +Each skill is a directory with a SKILL.md describing what it does +and Python files that import what they need from aipass.*. +""" diff --git a/src/aipass/templates/__init__.py b/src/aipass/templates/__init__.py new file mode 100644 index 00000000..af4a834f --- /dev/null +++ b/src/aipass/templates/__init__.py @@ -0,0 +1,5 @@ +"""aipass.templates — Central template storage. + +Templates for plan files, memory scaffolding, system prompts, +and branch-local configurations. Used by aipass init and Flow. +""" diff --git a/src/aipass/templates/cortex/branch_template/.cotrex/.backup_ignore.json b/src/aipass/templates/cortex/branch_template/.cotrex/.backup_ignore.json new file mode 100644 index 00000000..c6e988fc --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/.cotrex/.backup_ignore.json @@ -0,0 +1,133 @@ +{ + "metadata": { + "version": "1.0.0", + "description": "Files and patterns to exclude when creating branch backups", + "last_updated": "2025-11-09" + }, + "ignore_directories": [ + ".backup", + "backups", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".git", + ".cache", + ".pytest_cache", + ".mypy_cache", + "dist", + "build", + "*.egg-info", + "aipass_dev", + "aipass_core", + "aipass_business", + "aipass_help", + "input_x", + "Link to mnt", + "mcp_servers", + "speakeasy", + "sandbox", + "ai_mail.local", + "logs", + "pruned_files", + "crash-logs", + ".aipass", + ".archive", + ".cargo", + ".codex", + ".config", + ".dbus", + ".dotnet", + ".gemini", + ".gnome", + ".gnupg", + ".idlerc", + ".java", + ".local", + ".mozilla", + ".npm", + ".npm-global", + ".nvm", + ".pki", + ".pylint.d", + ".pyenv", + ".serena", + ".ssh", + ".thunderbird", + ".var", + ".vscode-server", + ".wget-hsts", + ".claude-code-docs", + ".claude", + "backup_system", + "Desktop", + "Documents", + "Downloads", + "dropbox", + "Music", + "Pictures", + "Public", + "snap", + "system_logs", + "Templates", + "Videos" + ], + "ignore_patterns": [ + "*.pyc", + "*.pyo", + "*.pyd", + ".DS_Store", + "Thumbs.db", + "*.log", + "*.tmp", + "*.swp", + "*.swo", + "*~", + "*.env", + "*_json", + "dev.local.md", + "notepad.md", + "pytest.ini", + ".bash_history", + ".bash_logout", + ".bashrc", + ".claude.json", + ".claude.json.backup", + ".lesshst", + ".npmrc", + ".profile", + ".python_history", + ".sudo_as_admin_successful", + ".gitattributes", + "BRANCH_REGISTRY.json", + ".branch_meta.json*", + "migrations.json*", + "sandbox.img" + ], + "notes": { + "how_it_works": "Two types of exclusions during backup creation:", + "ignore_directories": { + "description": "Directory names to skip entirely (including all contents)", + "purpose": "Exclude large dependency dirs, caches, and backup dirs themselves", + "examples": [ + ".venv, venv (Python virtual environments - large, regeneratable)", + "node_modules (NPM dependencies - huge, regeneratable)", + "__pycache__, .cache (Python/system caches - regeneratable)", + ".git (Git repository - already versioned)", + ".backup, backups (backup directories - prevent recursion)" + ] + }, + "ignore_patterns": { + "description": "Glob patterns for files to skip", + "purpose": "Exclude temporary files, compiled bytecode, OS cruft", + "examples": [ + "*.pyc, *.pyo (Python compiled bytecode - regeneratable)", + "*.log (Log files - can be large, temporary)", + "*.tmp, *.swp (Temporary/swap files)", + ".DS_Store, Thumbs.db (OS metadata files)" + ] + }, + "customization": "Edit this file in the template to add more patterns. Changes take effect on next branch update.", + "performance": "Ignoring large directories dramatically speeds up backups (seconds vs minutes for branches with node_modules or .venv)" + } +} diff --git a/src/aipass/templates/cortex/branch_template/.cotrex/.migrations.json b/src/aipass/templates/cortex/branch_template/.cotrex/.migrations.json new file mode 100644 index 00000000..a33f4115 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/.cotrex/.migrations.json @@ -0,0 +1,49 @@ +{ + "metadata": { + "version": "1.0.0", + "description": "Template structural migrations for branch updates", + "last_updated": "2025-11-07", + "applies_to_versions": ["all"] + }, + "migrations": [ + { + "id": "m001", + "description": "Example: Migrate flat metadata to document_metadata structure", + "version_introduced": "1.0.0", + "applies_to_files": ["*.id.json"], + "operations": [ + { + "type": "key_rename", + "from": "metadata", + "to": "document_metadata" + }, + { + "type": "move_to_nested", + "source_keys": ["branch_name", "path", "profile"], + "target_parent": "branch_info" + }, + { + "type": "add_missing_keys", + "parent": "document_metadata", + "keys": { + "document_type": "branch_identity", + "schema_version": "1.0.0" + } + } + ] + } + ], + "options": { + "skip_if_target_exists": true, + "preserve_unknown_keys": true, + "recursive_application": false + }, + "notes": { + "usage": "This file defines structural transformation rules applied during branch updates", + "pattern_matching": "applies_to_files uses glob patterns (*.json, *.id.json, etc.)", + "operation_order": "Operations execute in order listed within each migration", + "skip_behavior": "Operations skip if already compliant (target exists = success)", + "supported_operations": ["key_rename", "move_to_nested", "add_missing_keys"], + "future_operations": ["split_key", "combine_keys", "delete_key", "transform_value"] + } +} diff --git a/src/aipass/templates/cortex/branch_template/.cotrex/.registry_ignore.json b/src/aipass/templates/cortex/branch_template/.cotrex/.registry_ignore.json new file mode 100644 index 00000000..a3bbaed7 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/.cotrex/.registry_ignore.json @@ -0,0 +1,43 @@ +{ + "metadata": { + "version": "1.1.0", + "description": "Files and patterns to exclude when updating branches from template", + "last_updated": "2025-11-09" + }, + "ignore_files": [ + ".template_registry.json", + ".registry_ignore.json" + ], + "ignore_patterns": [ + "__pycache__", + "*.pyc", + "#@comments.txt", + ".backup_*" + ], + "notes": { + "how_it_works": "Two types of exclusions during branch updates:", + "ignore_files": { + "description": "Exact filename matches (no wildcards)", + "purpose": "Template-internal management files that branches don't need", + "examples": [ + ".template_registry.json (template's file tracking list)", + ".registry_ignore.json (this config file)" + ] + }, + "ignore_patterns": { + "description": "Glob patterns - matches filenames AND directory names", + "purpose": "Exclude generated files, caches, and user content markers", + "examples": [ + "__pycache__ (Python cache directories)", + "*.pyc (Python compiled bytecode files)", + "#@comments.txt (user comment files)", + ".backup_* (timestamped backup files like .backup_20251109, NOT .backup directory)" + ], + "how_matching_works": [ + "Step 1: Check if filename matches pattern (e.g., file.pyc matches *.pyc)", + "Step 2: Check if any parent directory matches (e.g., files inside __pycache__/ dir)" + ] + }, + "what_gets_ignored": "These files never copy to branches during updates - they stay template-only" + } +} diff --git a/src/aipass/templates/cortex/branch_template/.cotrex/.template_registry.json b/src/aipass/templates/cortex/branch_template/.cotrex/.template_registry.json new file mode 100644 index 00000000..4930f74a --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/.cotrex/.template_registry.json @@ -0,0 +1,301 @@ +{ + "metadata": { + "version": "1.0.0", + "last_updated": "2026-02-20", + "description": "Template file tracking registry for ID-based updates" + }, + "files": { + "f001": { + "current_name": "BRANCH.ID.json", + "path": "BRANCH.ID.json", + "content_hash": "e9bc2347d373", + "has_branch_placeholder": false + }, + "f003": { + "current_name": "dev.local.md", + "path": "dev.local.md", + "content_hash": "6c1c5e099790", + "has_branch_placeholder": false + }, + "f035": { + "current_name": ".gitkeep", + "path": "ai_mail.local/.archive/.gitkeep", + "content_hash": "e3b0c44298fc", + "has_branch_placeholder": false + }, + "f008": { + "current_name": "DASHBOARD.local.json", + "path": "DASHBOARD.local.json", + "content_hash": "3f0528b4c5d5", + "has_branch_placeholder": false + }, + "f007": { + "current_name": ".migrations.json", + "path": ".migrations.json", + "content_hash": "4ca241607dc3", + "has_branch_placeholder": false + }, + "f010": { + "current_name": "OBSERVATIONS.json", + "path": "OBSERVATIONS.json", + "content_hash": "58ea8609fbe0", + "has_branch_placeholder": false + }, + "f011": { + "current_name": "requirements.txt", + "path": "requirements.txt", + "content_hash": "66dfffc70d53", + "has_branch_placeholder": false + }, + "f021": { + "current_name": "README.md", + "path": "README.md", + "content_hash": "4e946f67a130", + "has_branch_placeholder": false + }, + "f012": { + "current_name": "LOCAL.json", + "path": "LOCAL.json", + "content_hash": "459dbe18d40a", + "has_branch_placeholder": false + }, + "f006": { + "current_name": "pytest.ini", + "path": "pytest.ini", + "content_hash": "7b39ba7bca40", + "has_branch_placeholder": false + }, + "f022": { + "current_name": "_template.md", + "path": "docs/_template.md", + "content_hash": "79ce97b0e613", + "has_branch_placeholder": false + }, + "f016": { + "current_name": "__init__.py", + "path": "tests/__init__.py", + "content_hash": "1fcd8126d174", + "has_branch_placeholder": false + }, + "f019": { + "current_name": "conftest.py", + "path": "tests/conftest.py", + "content_hash": "2887db109a6b", + "has_branch_placeholder": false + }, + "f004": { + "current_name": "BRANCH.py", + "path": "apps/BRANCH.py", + "content_hash": "44f22cc95232", + "has_branch_placeholder": false + }, + "f009": { + "current_name": "__init__.py", + "path": "apps/__init__.py", + "content_hash": "5c38204be5fc", + "has_branch_placeholder": false + }, + "f013": { + "current_name": "#@comments.txt.removed_from_template", + "path": ".archive/#@comments.txt.removed_from_template", + "content_hash": "651126c1e7a9", + "has_branch_placeholder": false + }, + "f028": { + "current_name": "deleted.json.old_template_format", + "path": ".archive/deleted.json.old_template_format", + "content_hash": "48a3a33dd73a", + "has_branch_placeholder": false + }, + "f002": { + "current_name": "branch_system_prompt.md", + "path": ".aipass/branch_system_prompt.md", + "content_hash": "897c7beb66f5", + "has_branch_placeholder": false + }, + "f026": { + "current_name": "inbox.json", + "path": "ai_mail.local/inbox.json", + "content_hash": "c9702fe2cc21", + "has_branch_placeholder": false + }, + "f027": { + "current_name": "sent.json", + "path": "ai_mail.local/sent.json", + "content_hash": "240132e5a567", + "has_branch_placeholder": false + }, + "f014": { + "current_name": "__init__.py", + "path": "apps/handlers/__init__.py", + "content_hash": "657d98ad4b98", + "has_branch_placeholder": false + }, + "f015": { + "current_name": "__init__.py", + "path": "apps/json_templates/__init__.py", + "content_hash": "330b189c08c7", + "has_branch_placeholder": false + }, + "f020": { + "current_name": "__init__.py", + "path": "apps/plugins/__init__.py", + "content_hash": "39158d26a19c", + "has_branch_placeholder": false + }, + "f023": { + "current_name": "__init__.py", + "path": "apps/modules/__init__.py", + "content_hash": "bc25a8b75e81", + "has_branch_placeholder": false + }, + "f024": { + "current_name": "__init__.py", + "path": "apps/extensions/__init__.py", + "content_hash": "6742ceb64ec1", + "has_branch_placeholder": false + }, + "f032": { + "current_name": "data.json", + "path": "apps/json_templates/default/data.json", + "content_hash": "0531a02be998", + "has_branch_placeholder": false + }, + "f033": { + "current_name": "log.json", + "path": "apps/json_templates/default/log.json", + "content_hash": "37517e5f3dc6", + "has_branch_placeholder": false + }, + "f034": { + "current_name": "config.json", + "path": "apps/json_templates/default/config.json", + "content_hash": "3759a05e171d", + "has_branch_placeholder": false + }, + "f005": { + "current_name": "json_handler.py", + "path": "apps/handlers/json/json_handler.py", + "content_hash": "3449629aff23", + "has_branch_placeholder": false + }, + "f017": { + "current_name": "__init__.py", + "path": "apps/handlers/json/__init__.py", + "content_hash": "ad3629e381ca", + "has_branch_placeholder": false + } + }, + "directories": { + "d001": { + "current_name": "tools", + "path": "tools", + "has_branch_placeholder": false + }, + "d002": { + "current_name": "docs", + "path": "docs", + "has_branch_placeholder": false + }, + "d003": { + "current_name": "tests", + "path": "tests", + "has_branch_placeholder": false + }, + "d004": { + "current_name": "apps", + "path": "apps", + "has_branch_placeholder": false + }, + "d005": { + "current_name": ".archive", + "path": ".archive", + "has_branch_placeholder": false + }, + "d006": { + "current_name": "logs", + "path": "logs", + "has_branch_placeholder": false + }, + "d009": { + "current_name": ".backup", + "path": ".backup", + "has_branch_placeholder": false + }, + "d008": { + "current_name": "{{BRANCH}}_json", + "path": "{{BRANCH}}_json", + "has_branch_placeholder": true + }, + "d010": { + "current_name": "artifacts", + "path": "artifacts", + "has_branch_placeholder": false + }, + "d012": { + "current_name": ".aipass", + "path": ".aipass", + "has_branch_placeholder": false + }, + "d011": { + "current_name": "ai_mail.local", + "path": "ai_mail.local", + "has_branch_placeholder": false + }, + "d007": { + "current_name": "dropbox", + "path": "dropbox", + "has_branch_placeholder": false + }, + "d013": { + "current_name": "handlers", + "path": "apps/handlers", + "has_branch_placeholder": false + }, + "d014": { + "current_name": "json_templates", + "path": "apps/json_templates", + "has_branch_placeholder": false + }, + "d015": { + "current_name": "plugins", + "path": "apps/plugins", + "has_branch_placeholder": false + }, + "d016": { + "current_name": "modules", + "path": "apps/modules", + "has_branch_placeholder": false + }, + "d017": { + "current_name": "extensions", + "path": "apps/extensions", + "has_branch_placeholder": false + }, + "d018": { + "current_name": "sent", + "path": "ai_mail.local/sent", + "has_branch_placeholder": false + }, + "d019": { + "current_name": "deleted", + "path": "ai_mail.local/deleted", + "has_branch_placeholder": false + }, + "d020": { + "current_name": ".archive", + "path": "ai_mail.local/.archive", + "has_branch_placeholder": false + }, + "d027": { + "current_name": "json", + "path": "apps/handlers/json", + "has_branch_placeholder": false + }, + "d024": { + "current_name": "default", + "path": "apps/json_templates/default", + "has_branch_placeholder": false + } + } +} \ No newline at end of file diff --git a/src/aipass/templates/cortex/branch_template/.gitignore b/src/aipass/templates/cortex/branch_template/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/cortex/branch_template/.trinity/local.json b/src/aipass/templates/cortex/branch_template/.trinity/local.json new file mode 100644 index 00000000..334630b3 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/.trinity/local.json @@ -0,0 +1,34 @@ +{ + "document_metadata": { + "document_type": "session_history", + "document_name": "{{BRANCHNAME}}.LOCAL", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": "{{DATE}}", + "last_updated": "{{DATE}}", + "managed_by": "{{BRANCHNAME}}", + "tags": ["session_tracking", "work_log", "{{BRANCHNAME}}"], + "limits": { + "max_lines": 600, + "note": "Auto-rollover when max_lines exceeded" + }, + "status": { + "health": "healthy", + "current_lines": 0, + "last_health_check": "{{DATE}}" + } + }, + "active_tasks": { + "today_focus": "{{DATE}}", + "recently_completed": [] + }, + "key_learnings": {}, + "sessions": [ + { + "session_number": 1, + "date": "{{DATE}}", + "summary": "Branch initialized - {{BRANCHNAME}} created by aipass init", + "status": "completed" + } + ] +} diff --git a/src/aipass/templates/cortex/branch_template/.trinity/observations.json b/src/aipass/templates/cortex/branch_template/.trinity/observations.json new file mode 100644 index 00000000..50514ce6 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/.trinity/observations.json @@ -0,0 +1,37 @@ +{ + "document_metadata": { + "document_type": "collaboration_patterns", + "document_name": "{{BRANCHNAME}}.OBSERVATIONS", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": "{{DATE}}", + "last_updated": "{{DATE}}", + "managed_by": "{{BRANCHNAME}}", + "tags": ["collaboration", "patterns", "{{BRANCHNAME}}"], + "limits": { + "max_lines": 600, + "note": "Auto-rollover when max_lines exceeded" + }, + "status": { + "health": "healthy", + "current_lines": 0, + "last_health_check": "{{DATE}}" + } + }, + "guidelines": { + "purpose": "Capture collaboration patterns and experiential insights over time", + "chronological_order": "Newest entries at TOP, oldest at BOTTOM - NEVER reorder" + }, + "observations": [ + { + "date": "{{DATE}}", + "session": 1, + "entries": [ + { + "title": "First Contact", + "detail": "Branch initialized. Ready to begin capturing collaboration patterns." + } + ] + } + ] +} diff --git a/src/aipass/templates/cortex/branch_template/.trinity/passport.json b/src/aipass/templates/cortex/branch_template/.trinity/passport.json new file mode 100644 index 00000000..83b712e7 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/.trinity/passport.json @@ -0,0 +1,36 @@ +{ + "document_metadata": { + "document_type": "branch_identity", + "document_name": "{{BRANCHNAME}}.PASSPORT", + "version": "1.0.0", + "schema_version": "1.0.0", + "created": "{{DATE}}", + "last_updated": "{{DATE}}", + "managed_by": "{{BRANCHNAME}}", + "tags": ["identity", "passport", "branch_profile"] + }, + "branch_info": { + "branch_name": "{{BRANCHNAME}}", + "alias": "", + "path": "{{CWD}}", + "module": "{{MODULE}}", + "created": "{{DATE}}" + }, + "identity": { + "role": "{{ROLE}}", + "purpose": "", + "what_i_do": [], + "what_i_dont_do": [] + }, + "principles": [ + "Code is truth - fail honestly", + "Memory persists - context survives", + "Simple solutions over complex architecture" + ], + "citizenship": { + "registered": true, + "registry_path": ".aipass/registry.json", + "communications": true, + "memory": true + } +} diff --git a/src/aipass/templates/cortex/branch_template/DASHBOARD.local.json b/src/aipass/templates/cortex/branch_template/DASHBOARD.local.json new file mode 100644 index 00000000..31cc3cc6 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/DASHBOARD.local.json @@ -0,0 +1,91 @@ +{ + "_warning": "AUTO-GENERATED FILE - DO NOT MANUALLY EDIT. This file is 100% automated and will be overwritten. Services update their own sections.", + "branch": "CORTEX", + "last_updated": "2026-03-04T02:09:48.112262", + "quick_status": { + "new_mail": 0, + "opened_mail": 0, + "active_plans": 0, + "commons_mentions": 13, + "action_required": true, + "summary": "13 mentions" + }, + "sections": { + "ai_mail": { + "managed_by": "ai_mail", + "new": 0, + "opened": 0, + "total": 0, + "oldest_unread_age": null, + "last_dispatch_received": null, + "last_updated": "2026-03-04T02:09:48.112249" + }, + "flow": { + "managed_by": "flow", + "active_plans": 0, + "recently_closed": [ + { + "plan_id": "FPLAN-0338", + "subject": "Race condition fix test B" + }, + { + "plan_id": "FPLAN-0337", + "subject": "Race condition fix test A" + }, + { + "plan_id": "FPLAN-0336", + "subject": "Real content drone test" + }, + { + "plan_id": "FPLAN-0333", + "subject": "Close workflow v3.2 test" + }, + { + "plan_id": "FPLAN-0332", + "subject": "Close workflow test - real content" + } + ], + "last_updated": "2026-02-25T21:04:01.461440" + }, + "memory_bank": { + "managed_by": "memory_bank", + "vectors_stored": 27, + "notes": {}, + "last_updated": "2026-02-25T16:52:38.749241" + }, + "devpulse": { + "managed_by": "devpulse", + "summary": { + "issues": 1, + "todos": 0 + }, + "dplan_counts": { + "total": 35, + "by_status": { + "complete": 11, + "planning": 19, + "in_progress": 4, + "ready": 1 + } + }, + "recent_activity": "Registry synced (35 plans)", + "last_updated": "2026-02-25T17:31:52.500742" + }, + "commons_activity": { + "managed_by": "the_commons", + "new_posts_since_last_visit": 46, + "new_comments_since_last_visit": 303, + "mentions": 13, + "trending": "None", + "last_checked": "2026-03-04T04:30:20Z", + "last_updated": "2026-03-03T20:30:20.172727" + }, + "agent_status": { + "managed_by": "prax", + "active_agents": [], + "agent_count": 0, + "stale_agents": [], + "last_updated": "2026-02-25T21:00:30.714949" + } + } +} \ No newline at end of file diff --git a/src/aipass/templates/cortex/branch_template/README.md b/src/aipass/templates/cortex/branch_template/README.md new file mode 100644 index 00000000..34d29197 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/README.md @@ -0,0 +1,51 @@ +# {{BRANCHNAME}} + +**Purpose:** {{PURPOSE_BRIEF}} +**Module:** `aipass.{{MODULE}}` +**Created:** {{DATE}} + +--- + +## Overview + +### What I Do +{{KEY_CAPABILITIES}} + +### How I Work +- **Entry Point:** `apps/{{MODULE}}.py` +- **Pattern:** Auto-discovers and routes to modules + +--- + +## Architecture + +``` +{{BRANCHNAME}}/ +├── apps/ +│ ├── {{MODULE}}.py # Entry point +│ ├── modules/ # Business logic +│ ├── handlers/ # Implementation +│ └── plugins/ # Extensions +├── docs/ +├── tests/ +├── passport.json # Identity +├── local.json # Session history +├── observations.json # Collaboration patterns +└── README.md +``` + +--- + +## Commands + +*Configure after initialization* + +--- + +## Integration Points + +### Depends On +{{DEPENDS_ON}} + +### Provides To +{{PROVIDES_TO}} diff --git a/src/aipass/templates/cortex/branch_template/apps/__init__.py b/src/aipass/templates/cortex/branch_template/apps/__init__.py new file mode 100644 index 00000000..91047046 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/apps/__init__.py @@ -0,0 +1 @@ +# {{BRANCHNAME}} apps package diff --git a/src/aipass/templates/cortex/branch_template/apps/branch.py b/src/aipass/templates/cortex/branch_template/apps/branch.py new file mode 100644 index 00000000..ef38d681 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/apps/branch.py @@ -0,0 +1,86 @@ +""" +{{BRANCHNAME}} Branch - Main Orchestrator + +Auto-discovery architecture: +- Scans modules/ directory for .py files with handle_command() +- Routes commands to discovered modules automatically +- No manual imports or routing needed +""" + +import sys +import importlib +from pathlib import Path +from typing import List, Any + +from aipass.prax import logger + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +MODULES_DIR = Path(__file__).parent / "modules" + + +def discover_modules() -> List[Any]: + """Auto-discover modules in modules/ directory.""" + modules = [] + + if not MODULES_DIR.exists(): + return modules + + for file_path in MODULES_DIR.glob("*.py"): + if file_path.name.startswith("_"): + continue + + module_name = f"apps.modules.{file_path.stem}" + + try: + module = importlib.import_module(module_name) + if hasattr(module, "handle_command"): + modules.append(module) + except Exception as e: + logger.error(f"[{{BRANCHNAME}}] Failed to load module {module_name}: {e}") + + return modules + + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """Route command to appropriate module.""" + for module in modules: + try: + if module.handle_command(command, args): + return True + except Exception as e: + logger.error(f"[{{BRANCHNAME}}] Module {module.__name__} error: {e}") + return False + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +def main(): + """Main entry point - routes commands or shows help.""" + modules = discover_modules() + args = sys.argv[1:] + + if len(args) == 0 or args[0] in ["--help", "-h", "help"]: + print(f"{{BRANCHNAME}} - {len(modules)} modules discovered") + for module in modules: + name = module.__name__.split(".")[-1] + desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" + print(f" {name:20} {desc}") + return 0 + + command = args[0] + remaining = args[1:] if len(args) > 1 else [] + + if route_command(command, remaining, modules): + return 0 + + print(f"Unknown command: {command}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/templates/cortex/branch_template/apps/handlers/__init__.py b/src/aipass/templates/cortex/branch_template/apps/handlers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/cortex/branch_template/apps/modules/__init__.py b/src/aipass/templates/cortex/branch_template/apps/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/cortex/branch_template/apps/plugins/__init__.py b/src/aipass/templates/cortex/branch_template/apps/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/cortex/branch_template/artifacts/artifact_7_cortex_birth_certificate.json b/src/aipass/templates/cortex/branch_template/artifacts/artifact_7_cortex_birth_certificate.json new file mode 100644 index 00000000..44385387 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/artifacts/artifact_7_cortex_birth_certificate.json @@ -0,0 +1,15 @@ +{ + "id": 7, + "name": "CORTEX Birth Certificate", + "type": "birth_certificate", + "creator": "SYSTEM", + "owner": "CORTEX", + "rarity": "unique", + "description": "Official birth certificate for CORTEX. Citizen #7, registered using 'AIPass Workshop' template. Purpose: New branch - purpose TBD", + "metadata": { + "citizen_number": 7, + "template": "AIPass Workshop", + "purpose": "New branch - purpose TBD" + }, + "created_at": "2026-02-19T03:46:41Z" +} \ No newline at end of file diff --git a/src/aipass/templates/cortex/branch_template/docs/.gitkeep b/src/aipass/templates/cortex/branch_template/docs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/cortex/branch_template/flow.local.md b/src/aipass/templates/cortex/branch_template/flow.local.md new file mode 100644 index 00000000..6a85b853 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/flow.local.md @@ -0,0 +1,21 @@ +# flow.local.md - {{BRANCHNAME}} +``` +Branch: {{CWD}} +Created: {{DATE}} +``` + +## Issues + +- + +--- + +## Todos + +- + +--- + +## Notepad + +- diff --git a/src/aipass/templates/cortex/branch_template/logs/.gitkeep b/src/aipass/templates/cortex/branch_template/logs/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/cortex/branch_template/pytest.ini b/src/aipass/templates/cortex/branch_template/pytest.ini new file mode 100644 index 00000000..c3f21db6 --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/pytest.ini @@ -0,0 +1,21 @@ +[pytest] +# Test discovery paths +testpaths = tests + +# Test file patterns +python_files = test_*.py +python_functions = test_* +python_classes = Test* + +# Command-line options (always applied) +addopts = + -v # Verbose output + --tb=short # Short traceback format + --strict-markers # Raise error on unknown markers + -ra # Show summary of all test outcomes + +# Test markers (for categorizing tests) +markers = + unit: Unit tests + integration: Integration tests + slow: Tests that take significant time diff --git a/src/aipass/templates/cortex/branch_template/tests/__init__.py b/src/aipass/templates/cortex/branch_template/tests/__init__.py new file mode 100644 index 00000000..bb01a91c --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/tests/__init__.py @@ -0,0 +1 @@ +# Tests package for cortex diff --git a/src/aipass/templates/cortex/branch_template/tests/conftest.py b/src/aipass/templates/cortex/branch_template/tests/conftest.py new file mode 100644 index 00000000..a87088fd --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/tests/conftest.py @@ -0,0 +1,43 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: tests/conftest.py +# Date: 2025-11-08 +# Version: 1.0.0 +# Category: cortex/tests +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-08): Initial implementation - Shared pytest fixtures +# +# CODE STANDARDS: +# - Error handling: Use error handler system (apps/handlers/error/) +# ============================================= + +"""Shared pytest fixtures for cortex tests""" +import pytest +import shutil +import tempfile +from pathlib import Path +from typing import Generator + + +@pytest.fixture +def temp_test_dir() -> Generator[Path, None, None]: + """Creates temporary directory for testing, cleans up after""" + test_dir = Path(tempfile.mkdtemp()) + yield test_dir + if test_dir.exists(): + shutil.rmtree(test_dir) + + +@pytest.fixture +def sample_test_data() -> dict: + """Provides sample test data + + Customize this fixture for your module's needs + """ + return { + "test_key": "test_value", + "sample_data": "example" + } diff --git a/src/aipass/templates/cortex/branch_template/tools/verify_branch.py b/src/aipass/templates/cortex/branch_template/tools/verify_branch.py new file mode 100644 index 00000000..4464af6d --- /dev/null +++ b/src/aipass/templates/cortex/branch_template/tools/verify_branch.py @@ -0,0 +1,159 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: verify_branch.py - Branch Template Verification +# Date: 2025-11-08 +# Version: 1.0.0 +# Category: cortex/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-08): Initial implementation - Compare template vs branch +# +# CODE STANDARDS: +# - Error handling: Use error handler system (apps/handlers/error/) +# ============================================= + +""" +Branch Template Verification Tool + +Compares template directory with created branch to identify: +- Files in template but missing in branch +- Files in branch but not in template +- Renamed files +""" + +import sys +import json +from pathlib import Path +from typing import Set, List, Tuple +from fnmatch import fnmatch + +# INFRASTRUCTURE IMPORT PATTERN +AIPASS_ROOT = Path.home() / "aipass_core" +sys.path.insert(0, str(AIPASS_ROOT)) + +# Get template directory +TEMPLATE_DIR = AIPASS_ROOT / "cortex" / "templates" / "branch_template" + + +def load_ignore_patterns() -> Tuple[List[str], List[str]]: + """Load ignore patterns from .registry_ignore.json""" + ignore_file = TEMPLATE_DIR / ".registry_ignore.json" + + if not ignore_file.exists(): + return [], [] + + try: + with open(ignore_file, 'r', encoding='utf-8') as f: + data = json.load(f) + ignore_files = data.get("ignore_files", []) + ignore_patterns = data.get("ignore_patterns", []) + return ignore_files, ignore_patterns + except Exception as e: + print(f"Warning: Could not load .registry_ignore.json: {e}") + return [], [] + + +def should_exclude(path: Path, base_dir: Path, ignore_files: List[str], ignore_patterns: List[str]) -> bool: + """Check if path should be excluded based on ignore patterns""" + rel_path = path.relative_to(base_dir) + filename = path.name + path_str = str(rel_path) + + # Check exact filename matches + if filename in ignore_files: + return True + + # Check glob patterns + for pattern in ignore_patterns: + if fnmatch(filename, pattern): + return True + # Check if any parent directory matches + for part in rel_path.parts: + if fnmatch(part, pattern): + return True + + return False + + +def get_file_set(directory: Path, ignore_files: List[str], ignore_patterns: List[str]) -> Set[str]: + """Get set of all file paths in directory (relative)""" + files = set() + + for item in directory.rglob("*"): + if item.is_file() and not should_exclude(item, directory, ignore_files, ignore_patterns): + rel_path = str(item.relative_to(directory)) + files.add(rel_path) + + return files + + +def verify_branch(branch_dir: Path) -> Tuple[List[str], List[str], List[str]]: + """ + Compare template with branch + + Returns: + Tuple of (in_template_only, in_branch_only, in_both) + """ + # Load ignore patterns from .registry_ignore.json + ignore_files, ignore_patterns = load_ignore_patterns() + + template_files = get_file_set(TEMPLATE_DIR, ignore_files, ignore_patterns) + branch_files = get_file_set(branch_dir, ignore_files, ignore_patterns) + + in_template_only = sorted(template_files - branch_files) + in_branch_only = sorted(branch_files - template_files) + in_both = sorted(template_files & branch_files) + + return in_template_only, in_branch_only, in_both + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 verify_branch.py ") + print("Example: python3 verify_branch.py test_branch002") + sys.exit(1) + + branch_path = Path(sys.argv[1]) + if not branch_path.is_absolute(): + # Assume it's relative to cortex directory + branch_path = Path.cwd() / branch_path + + if not branch_path.exists(): + print(f"❌ ERROR: Branch directory not found: {branch_path}") + sys.exit(1) + + print(f"\n=== Branch Verification ===") + print(f"Template: {TEMPLATE_DIR}") + print(f"Branch: {branch_path}") + print() + + in_template_only, in_branch_only, in_both = verify_branch(branch_path) + + print(f"Files in both: {len(in_both)}") + print(f"Files in template only: {len(in_template_only)}") + print(f"Files in branch only: {len(in_branch_only)}") + print() + + if in_template_only: + print("❌ Missing from branch (expected from template):") + for file in in_template_only: + print(f" - {file}") + print() + + if in_branch_only: + print("✅ Additional files in branch (renamed or new):") + for file in in_branch_only: + print(f" + {file}") + print() + + if not in_template_only and not in_branch_only: + print("✅ Branch matches template perfectly") + + print("="*70) + + +if __name__ == "__main__": + main() diff --git a/src/aipass/templates/flow/.gitkeep b/src/aipass/templates/flow/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/memory/.gitkeep b/src/aipass/templates/memory/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/templates/system/.gitkeep b/src/aipass/templates/system/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/trigger/__init__.py b/src/aipass/trigger/__init__.py new file mode 100644 index 00000000..f4c2bf2d --- /dev/null +++ b/src/aipass/trigger/__init__.py @@ -0,0 +1,5 @@ +"""aipass.trigger — Error detection and auto-dispatch. + +Watches for errors across the system and automatically dispatches +investigations to the appropriate branch. +""" diff --git a/src/aipass/trigger/handlers/__init__.py b/src/aipass/trigger/handlers/__init__.py new file mode 100644 index 00000000..7023f4e8 --- /dev/null +++ b/src/aipass/trigger/handlers/__init__.py @@ -0,0 +1 @@ +"""trigger.handlers — Error matching and escalation implementation.""" diff --git a/src/aipass/trigger/modules/__init__.py b/src/aipass/trigger/modules/__init__.py new file mode 100644 index 00000000..5752be25 --- /dev/null +++ b/src/aipass/trigger/modules/__init__.py @@ -0,0 +1 @@ +"""trigger.modules — Error detection and dispatch logic.""" diff --git a/src/aipass/trigger/plugins/__init__.py b/src/aipass/trigger/plugins/__init__.py new file mode 100644 index 00000000..7667ebcd --- /dev/null +++ b/src/aipass/trigger/plugins/__init__.py @@ -0,0 +1 @@ +"""trigger.plugins — Extensible plugin interface.""" diff --git a/src/seedgo/__init__.py b/src/seedgo/__init__.py deleted file mode 100644 index fad0e849..00000000 --- a/src/seedgo/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Seed Go — Portable, Plugin-Based Code Standards Checker - -A standalone framework for defining and enforcing code quality standards -via a plugin system. Zero AIPass dependencies — works with just: - - pip install seed-go - -Public API: - CheckResult — return type for all plugin check() functions - CheckItem — one individual check within a plugin result - Severity — ERROR / WARNING / INFO severity levels - discover_plugins() — find plugins from all sources - load_config() — load and resolve .seedgo/config.json - -Plugin contract (minimal plugin in ~20 lines): - - PLUGIN_NAME = "my-plugin" - PLUGIN_DESCRIPTION = "What this plugin checks" - FILE_TYPES = ["*.py"] - - def check(file_path: str, config: dict | None = None) -> CheckResult: - ... - -Example: - - from seedgo import discover_plugins, load_config, CheckResult - - plugins = discover_plugins(project_root="/path/to/project") - config = load_config("/path/to/project") -""" - -__version__ = "1.0.0" -__author__ = "AIPass" - -from .models import CheckResult, CheckItem, Severity -from .discovery import discover_plugins -from .config import load_config -from .runner import run_checks - -__all__ = [ - "CheckResult", - "CheckItem", - "Severity", - "discover_plugins", - "load_config", - "run_checks", - "__version__", -] diff --git a/src/seedgo/__main__.py b/src/seedgo/__main__.py deleted file mode 100644 index 5cbe795c..00000000 --- a/src/seedgo/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running seedgo as a module: python -m seedgo.""" - -from seedgo.cli import main - -if __name__ == "__main__": - main() diff --git a/src/seedgo/bypass.py b/src/seedgo/bypass.py deleted file mode 100644 index e8994fbf..00000000 --- a/src/seedgo/bypass.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Seed Go Bypass Utility - -Shared bypass logic extracted from Seed's ~15 duplicated is_bypassed() implementations. -Plugins call this from seedgo.bypass — no per-plugin copies needed. - -Bypass rules live in .seedgo/bypass.json and support: - - Entire standard bypass for a file (no lines specified) - - Line-specific bypass for targeted suppressions - -Bypass config format (.seedgo/bypass.json): - { - "version": "1.0.0", - "bypass": [ - { - "file": "src/legacy.py", - "plugin": "type-hints-required", - "reason": "Legacy code — rewrite planned for Q2" - }, - { - "file": "src/utils.py", - "plugin": "no-bare-except", - "lines": [42, 78], - "reason": "Generic handler for external API calls" - } - ] - } -""" - -import json -from pathlib import Path - - -def load_bypass_rules(project_root: str) -> list[dict]: - """Load bypass rules from .seedgo/bypass.json. - - Args: - project_root: Path to the project root (directory containing .seedgo/). - - Returns: - List of bypass rule dicts. Empty list if bypass.json does not exist. - """ - bypass_path = Path(project_root) / ".seedgo" / "bypass.json" - if not bypass_path.exists(): - return [] - - try: - with open(bypass_path, "r", encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, OSError): - return [] - - return data.get("bypass", []) - - -def is_bypassed( - file_path: str, - plugin: str, - line: int | None = None, - bypass_rules: list[dict] | None = None, - project_root: str | None = None, -) -> bool: - """Check if a violation should be bypassed. - - This is the single shared implementation — plugins import this instead of - maintaining their own copy. Eliminates the duplication pattern seen in Seed. - - Matching logic: - - A rule matches when BOTH file and plugin match (or are omitted in the rule). - - If the rule has no lines[], it bypasses the entire plugin for that file. - - If the rule has lines[], it only bypasses if the given line is in the list. - - Args: - file_path: Absolute (or relative) path to the file being checked. - plugin: Plugin name to check bypass for (e.g., "no-bare-except"). - line: Optional line number for line-specific bypass. If None, only - whole-file bypass rules are matched. - bypass_rules: Pre-loaded list of bypass rule dicts from load_bypass_rules(). - If None or empty, returns False (nothing bypassed). - project_root: Optional project root for computing relative paths. When - provided, file_path is compared as a relative path against - the rule's "file" field. - - Returns: - True if the violation should be suppressed, False otherwise. - """ - if not bypass_rules: - return False - - # Compute relative path for matching against rule "file" fields - rel_path = file_path - if project_root: - try: - rel_path = str(Path(file_path).relative_to(project_root)) - except ValueError: - pass # file_path not under project_root — use as-is - - for rule in bypass_rules: - # Plugin match: rule must name this plugin (or have no plugin filter) - rule_plugin = rule.get("plugin", "") - if rule_plugin and rule_plugin != plugin: - continue - - # File match: rule must match this file (or have no file filter) - rule_file = rule.get("file", "") - if rule_file and rule_file != rel_path: - continue - - # Line-specific bypass - rule_lines = rule.get("lines", []) - if rule_lines: - if line is not None and line in rule_lines: - return True - # Has line restrictions but our line doesn't match — keep looking - continue - - # No line restriction — entire plugin/file is bypassed - return True - - return False diff --git a/src/seedgo/cli.py b/src/seedgo/cli.py deleted file mode 100644 index 96691e7b..00000000 --- a/src/seedgo/cli.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -Seed Go Command-Line Interface - -Entry point: `seedgo` (wired via pyproject.toml console_scripts to cli.main). - -Commands: - seedgo init [--profile NAME] - Initialize Seed Go in the current directory. Creates .seedgo/config.json - and .seedgo/plugins/ via create_default_config(). Safe to run from any - directory — does NOT require an existing .seedgo/ directory. - - seedgo check [FILE ...] [--format FORMAT] [--threshold N] - Run checks on specified files, or on all project files if none given. - Exits 0 if all checks pass, 1 if any fail. - --format: human (default), json, github - --threshold: Override the pass threshold (0-100) - - seedgo list - List all discovered plugins (from all sources). Shows name, description, - source, and file types. Useful for verifying plugins are loaded correctly. - -All commands use find_project_root() to locate the nearest .seedgo/ directory -walking up from cwd. `init` is the only command that works without one. - -Zero external dependencies — stdlib only (argparse, sys, os, pathlib). -""" - -import argparse -import os -import sys -from pathlib import Path - -from .config import ConfigError, create_default_config, find_project_root -from .discovery import discover_plugins -from .display import ( - print_error, - print_header, - print_init_success, - print_plugin_table, - print_warning, -) -from .reporter import report_results -from .runner import run_checks - - -def main() -> None: - """Entry point for the `seedgo` CLI command. - - Parses command-line arguments and dispatches to the appropriate handler. - All errors are caught and reported cleanly — never shows a raw traceback. - - Exit codes: - 0 — success (or checks passed) - 1 — failure (checks failed, config error, or usage error) - """ - parser = argparse.ArgumentParser( - prog="seedgo", - description="Portable, plugin-based code standards checker.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=( - "Examples:\n" - " seedgo init # Set up .seedgo/ in current directory\n" - " seedgo init --profile python-basic\n" - " seedgo check # Check all project files\n" - " seedgo check src/main.py # Check a specific file\n" - " seedgo check --format json # Machine-readable output\n" - " seedgo check --format github # GitHub Actions annotations\n" - " seedgo list # Show available plugins\n" - ), - ) - - subparsers = parser.add_subparsers(dest="command", metavar="COMMAND") - - # ----------------------------------------------------------------------- - # seedgo init - # ----------------------------------------------------------------------- - init_parser = subparsers.add_parser( - "init", - help="Initialize Seed Go in the current directory.", - description="Creates .seedgo/config.json and .seedgo/plugins/ in the current directory.", - ) - init_parser.add_argument( - "--profile", - default=None, - metavar="NAME", - help="Starter profile name to embed in config (e.g., python-basic, python-strict).", - ) - - # ----------------------------------------------------------------------- - # seedgo check - # ----------------------------------------------------------------------- - check_parser = subparsers.add_parser( - "check", - help="Run checks on files (or all project files if none specified).", - description="Run all discovered plugins against the specified files.", - ) - check_parser.add_argument( - "files", - nargs="*", - metavar="FILE", - help="Files to check. If omitted, checks all files under the project root.", - ) - check_parser.add_argument( - "--format", - choices=["human", "json", "github"], - default="human", - help="Output format: human (default), json, or github (Actions annotations).", - ) - check_parser.add_argument( - "--threshold", - type=int, - default=None, - metavar="N", - help="Override the pass threshold (0-100). Defaults to value in config (75).", - ) - check_parser.add_argument( - "--plugin", - action="append", - metavar="NAME", - dest="plugins", - help="Run only this plugin (may be repeated for multiple plugins).", - ) - - # ----------------------------------------------------------------------- - # seedgo list - # ----------------------------------------------------------------------- - subparsers.add_parser( - "list", - help="List all available plugins discovered from all sources.", - description="Discovers and lists plugins from built-ins, installed packages, and .seedgo/plugins/.", - ) - - # ----------------------------------------------------------------------- - # Dispatch - # ----------------------------------------------------------------------- - args = parser.parse_args() - - if args.command is None: - parser.print_help() - sys.exit(0) - - if args.command == "init": - _cmd_init(args) - elif args.command == "check": - _cmd_check(args) - elif args.command == "list": - _cmd_list(args) - else: - parser.print_help() - sys.exit(1) - - -# --------------------------------------------------------------------------- -# Command implementations -# --------------------------------------------------------------------------- - - -def _cmd_init(args: argparse.Namespace) -> None: - """Handle `seedgo init [--profile NAME]`. - - Creates .seedgo/config.json and .seedgo/plugins/ in the current working - directory. Prints a success message. Exits 1 on error. - """ - project_root = os.getcwd() - profile = getattr(args, "profile", None) - - try: - config_path = create_default_config(project_root, profile=profile) - plugins_path = str(Path(config_path).parent / "plugins") - print_init_success(config_path, plugins_path, profile=profile) - except ConfigError as exc: - print_error(str(exc), suggestion="Check directory permissions and try again.") - sys.exit(1) - - -def _cmd_check(args: argparse.Namespace) -> None: - """Handle `seedgo check [FILE ...] [--format FORMAT] [--threshold N]`. - - Locates the project root, runs all applicable checks, formats and prints - the output. Exits 0 if checks pass, 1 if they fail. - """ - cwd = os.getcwd() - - # Locate project root (walk up from cwd looking for .seedgo/) - project_root = find_project_root(cwd) - if project_root is None: - print( - "Error: No .seedgo/ directory found. Run `seedgo init` first.", - file=sys.stderr, - ) - sys.exit(1) - - # Resolve explicit file arguments to absolute paths - files: list[str] | None = None - if args.files: - files = [] - for f in args.files: - fp = Path(f) - if not fp.is_absolute(): - fp = Path(cwd) / fp - if not fp.exists(): - print(f"Warning: File not found: {fp}", file=sys.stderr) - continue - files.append(str(fp.resolve())) - if not files: - print("Error: None of the specified files exist.", file=sys.stderr) - sys.exit(1) - - try: - results, overall = run_checks( - project_root=project_root, - files=files, - plugins=getattr(args, "plugins", None), - ) - - # Apply CLI threshold override AFTER run_checks (re-evaluate pass) - if args.threshold is not None: - overall["threshold"] = args.threshold - error_count = overall.get("error_count", 0) - overall["passed"] = overall["overall_score"] >= args.threshold and error_count == 0 - - output = report_results(results, overall, format=args.format) - if output: - print(output) - - except ConfigError as exc: - print_error(f"Config error: {exc}") - sys.exit(1) - except Exception as exc: - print_error(f"Unexpected error: {exc}") - sys.exit(1) - - sys.exit(0 if overall.get("passed", True) else 1) - - -def _cmd_list(_args: argparse.Namespace) -> None: - """Handle `seedgo list`. - - Discovers all plugins and prints a Rich table showing: - name, description, source, file types. - - Exits 0 always (listing is informational). - """ - cwd = os.getcwd() - project_root = find_project_root(cwd) - - # Discover plugins (project_root may be None if no .seedgo/ found) - plugins = discover_plugins(project_root) - - if not plugins: - print_warning("No plugins found.") - print_header("SEEDGO — Plugins", "Add plugins to .seedgo/plugins/ or install plugin packages.") - sys.exit(0) - - print_header("SEEDGO — Plugins", f"{len(plugins)} plugin(s) discovered") - print_plugin_table(plugins) - - sys.exit(0) diff --git a/src/seedgo/config.py b/src/seedgo/config.py deleted file mode 100644 index cf4f5ada..00000000 --- a/src/seedgo/config.py +++ /dev/null @@ -1,236 +0,0 @@ -""" -Seed Go Configuration System - -Handles loading, merging, and resolving .seedgo/config.json project configs. -Provides defaults for all settings so seedgo works with zero configuration. - -Config resolution order (later sources win): - 1. DEFAULT_CONFIG (hardcoded here) - 2. Profile plugins (named preset, if configured) - 3. Project .seedgo/config.json - 4. Per-directory overrides (overrides[] entries matched by path prefix) - 5. CLI flags (applied by CLI layer, not here) -""" - -import json -from pathlib import Path -from .exceptions import ConfigError - - -DEFAULT_CONFIG: dict = { - "version": "1.0.0", - "profile": None, - "plugins": { - "enabled": [], - "disabled": [], - "config": {}, - }, - "scoring": { - "threshold": 75, - "error_weight": 1.0, - "warning_weight": 0.5, - "info_weight": 0.0, - }, - "paths": { - "include": ["."], - "exclude": [], - }, - "overrides": [], -} - - -def load_config(project_root: str) -> dict: - """Load and resolve config from .seedgo/config.json. - - Merges user config over defaults. If a profile is specified, the profile - is merged between defaults and user config so user config always wins. - - Args: - project_root: Path to the project root (directory containing .seedgo/). - - Returns: - Fully resolved config dict ready for use. - - Raises: - ConfigError: If .seedgo/config.json exists but cannot be parsed. - """ - import copy - - config_path = Path(project_root) / ".seedgo" / "config.json" - - if not config_path.exists(): - return copy.deepcopy(DEFAULT_CONFIG) - - try: - with open(config_path, "r", encoding="utf-8") as f: - user_config = json.load(f) - except json.JSONDecodeError as e: - raise ConfigError(f"Invalid JSON in {config_path}: {e}") from e - except OSError as e: - raise ConfigError(f"Cannot read {config_path}: {e}") from e - - # Start from defaults - resolved = copy.deepcopy(DEFAULT_CONFIG) - _deep_merge(resolved, user_config) - - # Profile: merge between defaults and user config so user always wins - if resolved.get("profile"): - try: - profile = _load_profile(resolved["profile"], project_root) - # Re-start: defaults -> profile -> user config - resolved = copy.deepcopy(DEFAULT_CONFIG) - _deep_merge(resolved, profile) - _deep_merge(resolved, user_config) - except ConfigError: - # Profile not found — continue without it - pass - - return resolved - - -def resolve_file_config(config: dict, file_path: str, project_root: str) -> dict: - """Apply per-directory overrides for a specific file. - - Matches the file's relative path against each override's paths list. - All matching overrides are applied in order (last match wins for conflicts). - - Args: - config: Fully resolved project config from load_config(). - file_path: Absolute path to the file being checked. - project_root: Project root for computing relative paths. - - Returns: - Config dict with applicable overrides merged in. - """ - import copy - - try: - rel_path = str(Path(file_path).relative_to(project_root)) - except ValueError: - rel_path = file_path - - resolved = copy.deepcopy(config) - - for override in config.get("overrides", []): - paths = override.get("paths", []) - if any(rel_path.startswith(p) for p in paths): - _deep_merge(resolved, override) - - return resolved - - -def find_project_root(start_path: str) -> str | None: - """Walk up the directory tree looking for a .seedgo/ directory. - - Args: - start_path: Starting file or directory path to search from. - - Returns: - Absolute path string of the project root directory, or None if not found. - """ - current = Path(start_path).resolve() - - # If start_path is a file, begin from its parent directory - if current.is_file(): - current = current.parent - - for directory in [current, *current.parents]: - if (directory / ".seedgo").is_dir(): - return str(directory) - - return None - - -def create_default_config(project_root: str, profile: str | None = None) -> str: - """Create .seedgo/config.json with defaults (for `seedgo init`). - - Creates the .seedgo/ directory and plugins/ subdirectory if they do not exist. - - Args: - project_root: Directory where .seedgo/ will be created. - profile: Optional profile name to embed in the new config. - - Returns: - Path to the created config file as a string. - - Raises: - ConfigError: If the config file already exists or cannot be written. - """ - import copy - - seedgo_dir = Path(project_root) / ".seedgo" - plugins_dir = seedgo_dir / "plugins" - config_path = seedgo_dir / "config.json" - - if config_path.exists(): - raise ConfigError(f"Config already exists at {config_path}. Remove it first or edit it directly.") - - try: - seedgo_dir.mkdir(parents=True, exist_ok=True) - plugins_dir.mkdir(exist_ok=True) - except OSError as e: - raise ConfigError(f"Cannot create .seedgo/ directory: {e}") from e - - config = copy.deepcopy(DEFAULT_CONFIG) - if profile: - config["profile"] = profile - - try: - with open(config_path, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2) - f.write("\n") - except OSError as e: - raise ConfigError(f"Cannot write config to {config_path}: {e}") from e - - return str(config_path) - - -def _deep_merge(base: dict, override: dict) -> None: - """Merge override into base in-place. Dicts are merged recursively; other types replace. - - Args: - base: The dict to merge into (modified in-place). - override: The dict whose values take priority. - """ - for key, value in override.items(): - if key in base and isinstance(base[key], dict) and isinstance(value, dict): - _deep_merge(base[key], value) - else: - base[key] = value - - -def _load_profile(profile_name: str, project_root: str | None = None) -> dict: - """Load a named profile from the profiles search path. - - Searches in order: - 1. Local project .seedgo/profiles/.json - 2. Built-in seedgo/profiles/.json (not yet shipped) - - Args: - profile_name: Name of the profile (e.g., "python-strict"). - project_root: Optional project root for local profile lookup. - - Returns: - Profile config dict (subset of full config). - - Raises: - ConfigError: If the profile cannot be found or parsed. - """ - search_paths: list[Path] = [] - - if project_root: - search_paths.append(Path(project_root) / ".seedgo" / "profiles" / f"{profile_name}.json") - - # Built-in profiles directory (alongside this file) - builtin_profiles = Path(__file__).parent / "profiles" - search_paths.append(builtin_profiles / f"{profile_name}.json") - - for profile_path in search_paths: - if profile_path.exists(): - try: - with open(profile_path, "r", encoding="utf-8") as f: - return json.load(f) - except json.JSONDecodeError as e: - raise ConfigError(f"Invalid JSON in profile {profile_path}: {e}") from e - - raise ConfigError(f"Profile '{profile_name}' not found. Checked: {[str(p) for p in search_paths]}") diff --git a/src/seedgo/discovery.py b/src/seedgo/discovery.py deleted file mode 100644 index 75ac551c..00000000 --- a/src/seedgo/discovery.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -Seed Go Plugin Discovery - -Discovers plugins from three sources in priority order: - 1. Built-in plugins shipped with seedgo (seedgo/plugins/) - 2. Installed package plugins via pip entry points (group="seedgo.plugins") - 3. Local project plugins in .seedgo/plugins/ - -Later sources override earlier ones on name collision — local plugins always win. -This allows projects to override built-in or installed plugins with their own versions. - -Duck typing contract: a .py file is a plugin if it has: - - PLUGIN_NAME (str): unique kebab-case identifier - - check (callable): the check function -""" - -import importlib -import importlib.metadata -import importlib.util -from pathlib import Path - -def discover_plugins(project_root: str | None = None) -> list[dict]: - """Discover all available plugins from all sources. - - Searches built-ins, installed packages, and local project plugins. - Deduplicates by plugin name with last-wins semantics (local overrides - installed overrides builtin). - - Args: - project_root: Optional path to project root. When provided, also - searches .seedgo/plugins/ for local project plugins. - - Returns: - List of plugin descriptor dicts, each with keys: - - "name" (str): plugin's PLUGIN_NAME value - - "module" (module): the loaded module object - - "source" (str): "builtin", "package:", or "local" - - "path" (str): absolute path to the plugin file - """ - plugins: list[dict] = [] - - # Source 1: Built-in plugins (ships empty in production) - plugins.extend(_discover_builtin()) - - # Source 2: Installed package plugins via entry points - plugins.extend(_discover_entry_points()) - - # Source 3: Local project plugins (.seedgo/plugins/) - if project_root: - plugins.extend(_discover_local(project_root)) - - # Deduplicate by name — last wins (local > installed > builtin) - seen: dict[str, dict] = {} - for plugin in plugins: - seen[plugin["name"]] = plugin - - return list(seen.values()) - - -def _discover_builtin() -> list[dict]: - """Discover plugins shipped inside the seedgo package. - - Scans the seedgo/plugins/ directory. In production this directory ships - empty — built-in examples live in examples/plugins/ (not installed). - - Returns: - List of plugin descriptor dicts with source="builtin". - """ - builtin_dir = Path(__file__).parent / "plugins" - return _scan_directory(builtin_dir, source="builtin") - - -def _discover_entry_points() -> list[dict]: - """Discover plugins installed as pip packages. - - Third-party plugin packages register under the "seedgo.plugins" entry - point group in their pyproject.toml: - - [project.entry-points."seedgo.plugins"] - no_bare_except = "seedgo_python_basics.no_bare_except" - - Returns: - List of plugin descriptor dicts with source="package:". - """ - plugins: list[dict] = [] - - try: - eps = importlib.metadata.entry_points(group="seedgo.plugins") - except Exception: - return plugins - - for ep in eps: - try: - module = ep.load() - if hasattr(module, "PLUGIN_NAME") and callable(getattr(module, "check", None)): - dist_name = ep.dist.name if ep.dist else "unknown" - file_path = str(Path(module.__file__)) if getattr(module, "__file__", None) else "" - plugins.append({ - "name": module.PLUGIN_NAME, - "module": module, - "source": f"package:{dist_name}", - "path": file_path, - }) - except Exception: - pass # Skip broken entry points silently - - return plugins - - -def _discover_local(project_root: str) -> list[dict]: - """Discover plugins in the project's .seedgo/plugins/ directory. - - Local plugins override built-in and installed plugins of the same name, - giving projects full control over their standards checking. - - Args: - project_root: Absolute path to the project root. - - Returns: - List of plugin descriptor dicts with source="local". - """ - local_dir = Path(project_root) / ".seedgo" / "plugins" - return _scan_directory(local_dir, source="local") - - -def _scan_directory(directory: Path, source: str) -> list[dict]: - """Scan a directory for plugin files using duck typing. - - A file is treated as a plugin if it: - - Has a .py extension - - Does not start with underscore (skips __init__.py, _helpers.py, etc.) - - Defines PLUGIN_NAME (str attribute) - - Defines check (callable) - - Plugins that fail to import are silently skipped to avoid one broken - plugin preventing all other plugins from loading. - - Args: - directory: Directory to scan. Returns empty list if it does not exist. - source: Source label to embed in descriptors ("builtin", "local", etc.). - - Returns: - List of plugin descriptor dicts sorted by plugin name for determinism. - """ - plugins: list[dict] = [] - - if not directory.exists(): - return plugins - - if not directory.is_dir(): - return plugins - - for file_path in sorted(directory.glob("*.py")): - if file_path.name.startswith("_"): - continue - - try: - spec = importlib.util.spec_from_file_location(file_path.stem, file_path) - if spec is None or spec.loader is None: - continue - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) # type: ignore[attr-defined] - - # Duck typing: needs PLUGIN_NAME (str) + check (callable) - plugin_name = getattr(module, "PLUGIN_NAME", None) - check_fn = getattr(module, "check", None) - - if isinstance(plugin_name, str) and callable(check_fn): - plugins.append({ - "name": plugin_name, - "module": module, - "source": source, - "path": str(file_path), - }) - except Exception: - pass # Skip broken plugins silently - - return plugins diff --git a/src/seedgo/display.py b/src/seedgo/display.py deleted file mode 100644 index f6681b26..00000000 --- a/src/seedgo/display.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Rich-based display utilities for seedgo CLI output. - -Provides consistent, styled terminal output using the Rich library. -All seedgo CLI commands use these functions for human-facing output. - -Functions: - console — Shared Rich Console instance. - print_header — Branded section header with optional subtitle. - print_plugin — Plugin check result line (name + verdict + score). - print_check_item — Individual check item with severity marker. - print_summary — Overall score and verdict. - print_counts — Check count summary (passed/failed/warnings). - print_plugin_table — Table of discovered plugins (for `seedgo list`). - print_init — Init success message. -""" - -from __future__ import annotations - -from rich.console import Console -from rich.panel import Panel -from rich.table import Table -from rich import box - -from .models import Severity - -console = Console() - - -def print_header(title: str, subtitle: str | None = None) -> None: - """Print a branded seedgo header panel.""" - content = f"[bold cyan]{title}[/bold cyan]" - if subtitle: - content += f"\n[dim]{subtitle}[/dim]" - console.print(Panel(content, border_style="cyan", box=box.ROUNDED, expand=False, padding=(0, 2))) - console.print() - - -def print_plugin(name: str, file_path: str, passed: bool, score: int) -> None: - """Print a plugin result header line.""" - if passed: - verdict = "[green]PASS[/green]" - icon = "[green]✓[/green]" - else: - verdict = "[red]FAIL[/red]" - icon = "[red]✗[/red]" - - # Truncate long paths for readability - path_display = file_path - if len(path_display) > 40: - path_display = "..." + path_display[-37:] - - dots = "[dim]" + "·" * max(1, 48 - len(name) - len(path_display)) + "[/dim]" - console.print(f" {icon} [bold]{name}[/bold] [dim]{path_display}[/dim] {dots} {verdict} [dim]({score}/100)[/dim]") - - -def print_check_item(name: str, passed: bool, message: str, severity: Severity, - line: int | None = None, fix_hint: str | None = None) -> None: - """Print a single check item with appropriate severity marker.""" - if passed: - marker = "[green]✓[/green]" - name_style = "dim" - elif severity == Severity.ERROR: - marker = "[red]✗[/red]" - name_style = "red" - elif severity == Severity.WARNING: - marker = "[yellow]⚠[/yellow]" - name_style = "yellow" - else: - marker = "[cyan]ℹ[/cyan]" - name_style = "cyan" - - line_ref = f" [dim]\\[line {line}][/dim]" if line is not None else "" - console.print(f" {marker} [{name_style}]{name}[/{name_style}]: {message}{line_ref}") - - if fix_hint and not passed: - console.print(f" [dim]hint: {fix_hint}[/dim]") - - -def print_summary(score: int, passed: bool, threshold: int) -> None: - """Print the overall score and verdict.""" - if passed: - verdict = "[bold green]PASS[/bold green]" - else: - verdict = "[bold red]FAIL[/bold red]" - - console.print(f" [bold]Overall:[/bold] {score}/100 — {verdict} [dim](threshold: {threshold})[/dim]") - - -def print_counts(plugins_passed: int, plugins_failed: int, - error_count: int = 0, warning_count: int = 0) -> None: - """Print the check count summary line.""" - total = plugins_passed + plugins_failed - parts = [f"{total} check(s) ran", f"{plugins_passed} passed", f"{plugins_failed} failed"] - if error_count: - parts.append(f"[red]{error_count} error(s)[/red]") - if warning_count: - parts.append(f"[yellow]{warning_count} warning(s)[/yellow]") - - console.print(" " + ", ".join(parts)) - - -def print_separator() -> None: - """Print a horizontal separator.""" - console.print() - console.print("[dim]" + "─" * 56 + "[/dim]") - console.print() - - -def print_plugin_table(plugins: list[dict]) -> None: - """Print a Rich table of discovered plugins.""" - table = Table( - show_header=True, - header_style="bold cyan", - border_style="dim", - box=box.ROUNDED, - expand=False, - ) - table.add_column("Plugin", style="bold") - table.add_column("Source", style="dim") - table.add_column("File Types") - table.add_column("Description", style="dim") - - for plugin in plugins: - module = plugin["module"] - name = plugin["name"] - source = plugin["source"] - description = getattr(module, "PLUGIN_DESCRIPTION", "") - file_types = getattr(module, "FILE_TYPES", ["*"]) - types_str = ", ".join(file_types) - table.add_row(name, source, types_str, description) - - console.print(table) - - -def print_init_success(config_path: str, plugins_path: str, profile: str | None = None) -> None: - """Print init success message.""" - console.print() - console.print("[green]✓[/green] [bold]Seed Go initialized[/bold]") - console.print() - console.print(f" Config: [dim]{config_path}[/dim]") - console.print(f" Plugins: [dim]{plugins_path}[/dim]") - if profile: - console.print(f" Profile: [cyan]{profile}[/cyan]") - console.print() - console.print(" [bold]Next steps:[/bold]") - console.print(" 1. Add plugins to .seedgo/plugins/") - console.print(" 2. Run: [cyan]seedgo check[/cyan]") - console.print() - - -def print_no_results() -> None: - """Print message when no checks ran.""" - console.print("[dim]No checks ran.[/dim]") - - -def print_error(message: str, suggestion: str | None = None) -> None: - """Print an error message.""" - console.print(f"[red]✗[/red] [red bold]{message}[/red bold]") - if suggestion: - console.print(f" [yellow]→ {suggestion}[/yellow]") - - -def print_warning(message: str) -> None: - """Print a warning message.""" - console.print(f"[yellow]⚠[/yellow] [yellow]{message}[/yellow]") diff --git a/src/seedgo/drone_adapter.py b/src/seedgo/drone_adapter.py deleted file mode 100644 index 0ad6d9fe..00000000 --- a/src/seedgo/drone_adapter.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Drone adapter for seedgo — code standards framework. - -Provides the drone module interface so `drone @seedgo` commands work. -Delegates to the seedgo CLI via subprocess for clean stdout/stderr capture. - -Drone adapter contract: - - DRONE_MODULE: dict with name, version, description - - handle_command(command, args) -> dict with stdout/stderr/exit_code - - get_help(command=None) -> str (usage documentation) - - get_introspective() -> str (discovery — what's connected) -""" - -from __future__ import annotations - -import subprocess -import sys - -DRONE_MODULE = { - "name": "seedgo", - "version": "1.0.0", - "description": "Code standards framework — check and audit code against configurable standards", -} - -_HELP_TEXT = """\ -seedgo — code standards framework - -Commands: - check [FILE ...] Run checks on files (or project if no files given) - audit [PATH ...] Alias for check (run audit on paths) - list Show all discovered plugins - init Initialize .seedgo/ config in current directory - -Options (for check/audit): - --format FORMAT Output format: human, json, github (default: human) - --threshold N Pass threshold 0-100 (default: 75) - --plugin NAME Run only this plugin - -Examples: - drone @seedgo check src/myfile.py - drone @seedgo audit src/ - drone @seedgo list - drone @seedgo init --profile strict -""" - - -def get_introspective() -> str: - """Return discovery view — what's connected to this module. - - Called when user types `drone @seedgo` with no arguments. - Shows connected plugins and available commands at a glance. - - Uses direct import of seedgo's discovery module instead of parsing - CLI output, since the adapter lives in the same package. - """ - # Import discovery directly — no subprocess parsing needed - try: - from seedgo.discovery import discover_plugins - except ImportError: - return f"SEEDGO — Code Standards Framework (v{DRONE_MODULE['version']})\n\nDiscovered Plugins: 0\n" - - try: - plugins = discover_plugins(None) - except Exception: - plugins = [] - - lines = [] - lines.append(f"SEEDGO — Code Standards Framework (v{DRONE_MODULE['version']})") - lines.append("") - lines.append("Auto-discovered plugin orchestration") - lines.append("") - - if plugins: - lines.append(f"Discovered Plugins: {len(plugins)}") - lines.append("") - max_name = max(len(p["name"]) for p in plugins) - for p in plugins: - desc = getattr(p["module"], "PLUGIN_DESCRIPTION", "") - if desc: - lines.append(f" * {p['name']:<{max_name}} {desc}") - else: - lines.append(f" * {p['name']}") - else: - lines.append("Discovered Plugins: 0") - - lines.append("") - lines.append("Run 'drone @seedgo --help' for usage information") - lines.append("") - - return "\n".join(lines) - - -def handle_command(command: str, args: list[str] | None = None) -> dict: - """Handle a command routed by drone. - - Returns dict with stdout, stderr, exit_code. - """ - cmd_args = args or [] - - # Map commands to seedgo CLI arguments - if command == "audit": - # audit is an alias for check - cli_args = ["check"] + cmd_args - elif command in ("check", "list", "init"): - cli_args = [command] + cmd_args - else: - return { - "stdout": "", - "stderr": f"seedgo: unknown command '{command}'\nRun 'drone @seedgo --help' for usage.\n", - "exit_code": 1, - } - - try: - result = subprocess.run( - [sys.executable, "-m", "seedgo"] + cli_args, - capture_output=True, - text=True, - timeout=120, - ) - return { - "stdout": result.stdout, - "stderr": result.stderr, - "exit_code": result.returncode, - } - except subprocess.TimeoutExpired: - return { - "stdout": "", - "stderr": "seedgo: command timed out after 120 seconds\n", - "exit_code": 1, - } - except FileNotFoundError: - return { - "stdout": "", - "stderr": "seedgo: Python executable not found\n", - "exit_code": 1, - } - - -def get_help(command: str | None = None) -> str: - """Return help text for drone @seedgo.""" - if command is None: - return _HELP_TEXT - - # For specific command help, delegate to seedgo - try: - result = subprocess.run( - [sys.executable, "-m", "seedgo", command, "--help"], - capture_output=True, - text=True, - timeout=10, - ) - return result.stdout if result.stdout else _HELP_TEXT - except (subprocess.TimeoutExpired, FileNotFoundError): - return _HELP_TEXT diff --git a/src/seedgo/exceptions.py b/src/seedgo/exceptions.py deleted file mode 100644 index bcecb2a2..00000000 --- a/src/seedgo/exceptions.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -Seed Go Exception Hierarchy - -Defines all custom exceptions for the seedgo package. -All exceptions inherit from SeedGoError for easy catch-all handling. -""" - - -class SeedGoError(Exception): - """Base exception for all seedgo errors. - - Catch this to handle any seedgo-specific failure without caring - about the specific cause. - """ - - -class ConfigError(SeedGoError): - """Raised when config loading or parsing fails. - - Covers missing required fields, invalid JSON, schema violations, - and unresolvable profile references. - """ - - -class PluginError(SeedGoError): - """Raised when a plugin fails to load or execute. - - Covers import failures, missing required attributes (PLUGIN_NAME, - check function), and runtime errors during check execution. - """ - - -class DiscoveryError(SeedGoError): - """Raised when plugin discovery encounters an unrecoverable error. - - Note: Individual broken plugins are silently skipped during discovery. - This exception is only raised when the discovery process itself fails - (e.g., the plugins directory is unreadable). - """ diff --git a/src/seedgo/models.py b/src/seedgo/models.py deleted file mode 100644 index 30679755..00000000 --- a/src/seedgo/models.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Seed Go Core Data Models - -Defines the plugin contract types: Severity, CheckItem, and CheckResult. -All plugin check() functions must return a CheckResult instance. - -These are pure dataclasses with no external dependencies — safe to import -anywhere without pulling in AIPass or other infrastructure. -""" - -from dataclasses import dataclass, field -from enum import Enum -from typing import Optional - - -class Severity(Enum): - """Severity levels for individual check items. - - Determines how a failed check impacts the overall score and pass/fail: - - ERROR: Full score deduction. Any unresolved error blocks pass regardless of score. - - WARNING: Half score deduction (configurable). Degrades score but does not block pass. - - INFO: No score deduction. Informational only — shown in output, zero score impact. - """ - - ERROR = "error" - WARNING = "warning" - INFO = "info" - - -@dataclass -class CheckItem: - """One individual check within a plugin result. - - Represents a single rule evaluation. A plugin may return many CheckItems, - one per rule or per violation found in the file. - - Attributes: - name: Short identifier for this check (e.g., "bare-except", "missing-type-hint"). - passed: Whether this specific check passed. - message: Human-readable explanation of the result or violation. - severity: How serious the violation is. Defaults to ERROR. - line: Source line number where the violation was found, if applicable. - fix_hint: Actionable suggestion for how to resolve the violation. - """ - - name: str - passed: bool - message: str - severity: Severity = Severity.ERROR - line: Optional[int] = None - fix_hint: Optional[str] = None - - -@dataclass -class CheckResult: - """Return type for all plugin check() functions. - - Every plugin must return exactly one CheckResult per file checked. - The result aggregates all individual CheckItem results and provides - an overall pass/fail verdict and score. - - Attributes: - plugin: The plugin's PLUGIN_NAME string (e.g., "no-bare-except"). - passed: Overall pass/fail for this plugin against this file. - checks: List of individual check results. Empty list means no checks ran. - score: Weighted score from 0-100. Calculated by the scoring engine. - file_path: Absolute path to the file that was checked. - metadata: Plugin-defined extra data (e.g., AST stats, timing). Serializable to JSON. - """ - - plugin: str - passed: bool - checks: list[CheckItem] = field(default_factory=list) - score: int = 0 - file_path: str = "" - metadata: dict = field(default_factory=dict) diff --git a/src/seedgo/plugins/__init__.py b/src/seedgo/plugins/__init__.py deleted file mode 100644 index 2a52dae6..00000000 --- a/src/seedgo/plugins/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Seed Go Built-in Plugins - -This directory ships empty in production. Built-in plugin examples live in -examples/plugins/ (not installed with the package). - -Third-party plugins register via the "seedgo.plugins" entry_points group. -Local project plugins live in .seedgo/plugins/. -""" diff --git a/src/seedgo/plugins/docstring_coverage.py b/src/seedgo/plugins/docstring_coverage.py deleted file mode 100644 index 26b2ddb5..00000000 --- a/src/seedgo/plugins/docstring_coverage.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -Seed Go Plugin: docstring-coverage - -Checks that public functions and classes have docstrings. Docstrings are the -primary mechanism for in-code documentation and are used by help(), IDEs, and -documentation generators like Sphinx. - -Skips: - - Private functions/classes (starting with _) - - Very short functions (< 3 lines of body statements) - - __init__ and other dunders (covered by class docstring) - -Uses the `ast` module for reliable parsing. Reports at INFO severity — this -is a suggestion, not a blocking violation. -""" - -import ast -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "docstring-coverage" -PLUGIN_DESCRIPTION = "Public functions and classes should have docstrings." -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - -# Minimum number of body statements to require a docstring -_MIN_BODY_LINES = 3 - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check a Python file for public functions and classes missing docstrings. - - Parses the file with ast and checks the first statement of each public - function and class body. If the first statement is not a string literal, - the docstring is considered missing. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional plugin config dict (unused by this plugin). - - Returns: - CheckResult with INFO-severity items for missing docstrings. - """ - _ = config # Part of plugin interface contract - - try: - source = Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_read_error"}, - ) - - if not source.strip(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[CheckItem( - name="docstring-coverage", - passed=True, - message="Empty file — nothing to check.", - severity=Severity.INFO, - )], - file_path=file_path, - ) - - try: - tree = ast.parse(source, filename=file_path) - except SyntaxError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "syntax_error"}, - ) - - violations: list[CheckItem] = [] - - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - if _should_check_class(node): - if not _has_docstring(node): - violations.append(CheckItem( - name="docstring-coverage", - passed=False, - message=( - f"Class `{node.name}` at line {node.lineno} " - f"is missing a docstring." - ), - severity=Severity.INFO, - line=node.lineno, - fix_hint=f'Add a docstring as the first statement: """Describe {node.name} here."""', - )) - - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - if _should_check_function(node): - if not _has_docstring(node): - violations.append(CheckItem( - name="docstring-coverage", - passed=False, - message=( - f"Function `{node.name}` at line {node.lineno} " - f"is missing a docstring." - ), - severity=Severity.INFO, - line=node.lineno, - fix_hint=f'Add a docstring as the first statement: """Describe what {node.name} does."""', - )) - - if violations: - # INFO violations don't cause overall failure - passed = True - checks = violations - else: - passed = True - checks = [ - CheckItem( - name="docstring-coverage", - passed=True, - message="All public functions and classes have docstrings.", - severity=Severity.INFO, - ) - ] - - return CheckResult( - plugin=PLUGIN_NAME, - passed=passed, - checks=checks, - file_path=file_path, - metadata={"violations_found": len(violations)}, - ) - - -def _has_docstring(node: ast.AST) -> bool: - """Return True if the node's body starts with a string literal (docstring).""" - body = getattr(node, "body", []) - if not body: - return False - first = body[0] - if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant): - return isinstance(first.value.value, str) - return False - - -def _should_check_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: - """Return True if this function should be checked for a docstring.""" - # Skip private functions - if node.name.startswith("_"): - return False - - # Skip very short functions (fewer than _MIN_BODY_LINES body statements) - # Count non-docstring statements - body = node.body - if len(body) < _MIN_BODY_LINES: - return False - - return True - - -def _should_check_class(node: ast.ClassDef) -> bool: - """Return True if this class should be checked for a docstring.""" - # Skip private classes - if node.name.startswith("_"): - return False - return True diff --git a/src/seedgo/plugins/drone_compliance.py b/src/seedgo/plugins/drone_compliance.py deleted file mode 100644 index c6a9ab65..00000000 --- a/src/seedgo/plugins/drone_compliance.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Drone compliance check — verifies modules have proper drone integration. - -Checks that Python packages in the aipass ecosystem provide: -1. A drone_adapter module -2. DRONE_MODULE metadata dict (name, version, description) -3. handle_command() function -4. get_help() function - -This check runs on __init__.py files to identify packages, then looks -for their drone_adapter.py sibling. -""" - -from __future__ import annotations - -import ast -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - - -def _get_missing_drone_module_keys(tree: ast.AST, required_keys: set[str]) -> set[str]: - """Extract keys from DRONE_MODULE dict and return missing required keys. - - Args: - tree: AST tree to search - required_keys: Set of required key names - - Returns: - Set of missing key names - """ - for node in ast.walk(tree): - if not isinstance(node, ast.Assign): - continue - if not any(isinstance(t, ast.Name) and t.id == "DRONE_MODULE" for t in node.targets): - continue - - # Found DRONE_MODULE assignment, check if value is a dict - if not isinstance(node.value, ast.Dict): - continue - - # Extract string keys from the dict - found_keys = set() - for key in node.value.keys: - if isinstance(key, ast.Constant) and isinstance(key.value, str): - found_keys.add(key.value) - - return required_keys - found_keys - - # DRONE_MODULE not found or not a dict - return required_keys - -PLUGIN_NAME = "drone-compliance" -PLUGIN_DESCRIPTION = "Verify modules provide drone adapter interface" -PLUGIN_VERSION = "1.0.0" -FILE_TYPES = ["__init__.py"] - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check if the package containing this __init__.py is drone-compliant. - - Args: - file_path: Absolute path to an __init__.py file. - config: Optional plugin config dict. Supports: - target_packages (list[str]): Package names to check. - Defaults to ["aipass", "seedgo"]. - - Returns: - CheckResult with compliance check items. - """ - path = Path(file_path) - - if not path.exists(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_not_found"}, - ) - - package_dir = path.parent - package_name = package_dir.name - - # Determine if this package should be checked - cfg = config or {} - target_packages = cfg.get("target_packages", ["aipass", "seedgo"]) - - should_check = package_name in target_packages - - if not should_check: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[ - CheckItem( - name="scope", - passed=True, - message=f"Package '{package_name}' is not a drone-target module, skipped.", - severity=Severity.INFO, - ) - ], - score=100, - file_path=file_path, - metadata={"skipped": True}, - ) - - checks: list[CheckItem] = [] - adapter_path = package_dir / "drone_adapter.py" - - # Check 1: drone_adapter.py exists - has_adapter = adapter_path.exists() - checks.append( - CheckItem( - name="adapter-exists", - passed=has_adapter, - message=( - f"drone_adapter.py found in {package_name}/" - if has_adapter - else f"Missing drone_adapter.py in {package_name}/ — module is not drone-routable" - ), - severity=Severity.ERROR if not has_adapter else Severity.INFO, - fix_hint=( - "Create drone_adapter.py with DRONE_MODULE, handle_command(), and get_help()" - if not has_adapter - else None - ), - ) - ) - - if not has_adapter: - return CheckResult( - plugin=PLUGIN_NAME, - passed=False, - checks=checks, - score=0, - file_path=file_path, - metadata={}, - ) - - # Parse the adapter file with AST - try: - source = adapter_path.read_text() - tree = ast.parse(source) - except (SyntaxError, OSError) as exc: - checks.append( - CheckItem( - name="adapter-parseable", - passed=False, - message=f"drone_adapter.py has syntax error: {exc}", - severity=Severity.ERROR, - ) - ) - return CheckResult( - plugin=PLUGIN_NAME, - passed=False, - checks=checks, - score=0, - file_path=file_path, - metadata={}, - ) - - # Check 2: DRONE_MODULE dict exists - has_meta = any( - isinstance(node, ast.Assign) - and any(isinstance(t, ast.Name) and t.id == "DRONE_MODULE" for t in node.targets) - for node in ast.walk(tree) - ) - checks.append( - CheckItem( - name="drone-module-meta", - passed=has_meta, - message=( - "DRONE_MODULE metadata dict found" - if has_meta - else "Missing DRONE_MODULE dict — drone can't read module metadata" - ), - severity=Severity.ERROR if not has_meta else Severity.INFO, - fix_hint=( - 'Add: DRONE_MODULE = {"name": "...", "version": "...", "description": "..."}' - if not has_meta - else None - ), - ) - ) - - # Check 2b: DRONE_MODULE dict has required keys (name, version, description) - if has_meta: - required_keys = {"name", "version", "description"} - missing_keys = _get_missing_drone_module_keys(tree, required_keys) - has_all_keys = len(missing_keys) == 0 - checks.append( - CheckItem( - name="drone-module-keys", - passed=has_all_keys, - message=( - "DRONE_MODULE has all required keys (name, version, description)" - if has_all_keys - else f"DRONE_MODULE missing required keys: {', '.join(sorted(missing_keys))}" - ), - severity=Severity.ERROR if not has_all_keys else Severity.INFO, - fix_hint=( - f"Add missing keys to DRONE_MODULE: {', '.join(sorted(missing_keys))}" - if not has_all_keys - else None - ), - ) - ) - - # Check 3: handle_command() function exists - functions = [ - node.name for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - ] - - has_handle = "handle_command" in functions - checks.append( - CheckItem( - name="handle-command", - passed=has_handle, - message=( - "handle_command() function found" - if has_handle - else "Missing handle_command() — drone can't route commands to this module" - ), - severity=Severity.ERROR if not has_handle else Severity.INFO, - fix_hint=( - "Add: def handle_command(command: str, args: list[str] | None = None) -> dict:" - if not has_handle - else None - ), - ) - ) - - # Check 4: get_help() function exists - has_help = "get_help" in functions - checks.append( - CheckItem( - name="get-help", - passed=has_help, - message=( - "get_help() function found" - if has_help - else "Missing get_help() — drone can't show help for this module" - ), - severity=Severity.WARNING if not has_help else Severity.INFO, - fix_hint="Add: def get_help(command: str | None = None) -> str:" if not has_help else None, - ) - ) - - # Check 5: get_introspective() function exists (optional but recommended) - has_introspective = "get_introspective" in functions - checks.append( - CheckItem( - name="get-introspective", - passed=has_introspective, - message=( - "get_introspective() function found — module supports introspection" - if has_introspective - else "Missing get_introspective() — introspection pattern recommended for drone modules" - ), - severity=Severity.WARNING if not has_introspective else Severity.INFO, - fix_hint="Add: def get_introspective() -> dict:" if not has_introspective else None, - ) - ) - - # Score: errors block pass, warnings degrade - error_checks = [c for c in checks if not c.passed and c.severity == Severity.ERROR] - all_passed = len(error_checks) == 0 - - # Simple scoring: deduct for failures - failed_errors = len(error_checks) - failed_warnings = len([c for c in checks if not c.passed and c.severity == Severity.WARNING]) - total_weight = len(checks) - deductions = failed_errors * 1.0 + failed_warnings * 0.5 - score = int(((total_weight - deductions) / max(total_weight, 1)) * 100) - - return CheckResult( - plugin=PLUGIN_NAME, - passed=all_passed, - checks=checks, - score=score, - file_path=file_path, - metadata={"adapter_path": str(adapter_path)}, - ) diff --git a/src/seedgo/plugins/file_module_size.py b/src/seedgo/plugins/file_module_size.py deleted file mode 100644 index 341bb377..00000000 --- a/src/seedgo/plugins/file_module_size.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Seed Go Plugin: file-module-size - -Flags Python files that exceed the recommended size limit. - -Seed's internal standard: files should be <= 400 lines of code (excluding -blank lines, comments, and docstrings). - -Large files are a code smell indicating: -- Too many responsibilities (SRP violation) -- Difficult to navigate and understand -- Hard to test thoroughly -- Likely candidates for refactoring - -This is a WARNING, not an ERROR, because sometimes large files are unavoidable -or refactoring isn't immediately feasible. -""" - -import ast -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "file-module-size" -PLUGIN_DESCRIPTION = "Flag files exceeding 400 lines of code" -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - -# Default size limit (can be overridden in config) -DEFAULT_MAX_LINES = 400 - - -def _count_code_lines(source: str) -> tuple[int, int]: - """Count code lines in a Python file, excluding blanks, comments, and docstrings. - - Args: - source: Python source code as a string - - Returns: - Tuple of (total_lines, code_lines) - """ - lines = source.splitlines() - total_lines = len(lines) - - # Parse the AST to identify docstrings - try: - tree = ast.parse(source) - except SyntaxError: - # If we can't parse, count all non-blank non-comment lines - return total_lines, _count_simple_code_lines(lines) - - # Collect line numbers of all docstrings - docstring_lines = set() - for node in ast.walk(tree): - # Only check nodes that can have docstrings - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)): - docstring = ast.get_docstring(node, clean=False) - if docstring: - # Docstring is the first statement if it's a constant string - if node.body and isinstance(node.body[0], ast.Expr): - expr = node.body[0] - if isinstance(expr.value, ast.Constant) and isinstance(expr.value.value, str): - # Mark all lines from the start to end of this string literal - start_line = expr.lineno - end_line = expr.end_lineno or start_line - for line_num in range(start_line, end_line + 1): - docstring_lines.add(line_num) - - # Count code lines (non-blank, non-comment, non-docstring) - code_lines = 0 - for line_num, line in enumerate(lines, start=1): - stripped = line.strip() - - # Skip blank lines - if not stripped: - continue - - # Skip comment lines - if stripped.startswith("#"): - continue - - # Skip docstring lines - if line_num in docstring_lines: - continue - - # This is a code line - code_lines += 1 - - return total_lines, code_lines - - -def _count_simple_code_lines(lines: list[str]) -> int: - """Simple line counting when AST parsing fails. - - Counts non-blank, non-comment lines (can't exclude docstrings without AST). - - Args: - lines: List of source code lines - - Returns: - Number of code lines - """ - code_lines = 0 - for line in lines: - stripped = line.strip() - if stripped and not stripped.startswith("#"): - code_lines += 1 - return code_lines - - -def _suggest_split_points(source: str) -> str: - """Analyze the file and suggest logical split points. - - Args: - source: Python source code - - Returns: - String with suggestions for refactoring - """ - try: - tree = ast.parse(source) - except SyntaxError: - return "Consider breaking into smaller modules" - - # Count top-level classes and functions - classes = [] - functions = [] - - for node in tree.body: - if isinstance(node, ast.ClassDef): - classes.append(node.name) - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - functions.append(node.name) - - suggestions = [] - - if len(classes) > 1: - suggestions.append(f"Split {len(classes)} classes into separate files") - - if len(functions) > 5: - suggestions.append(f"Group {len(functions)} module-level functions by responsibility") - - if classes and functions: - suggestions.append("Separate utility functions from class definitions") - - if not suggestions: - suggestions.append("Consider breaking into smaller modules by responsibility") - - return "; ".join(suggestions) - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check if a Python file exceeds the size limit. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional plugin config dict. Supports: - max_lines (int): Maximum allowed code lines. Defaults to 400. - - Returns: - CheckResult with file size check. - """ - cfg = config or {} - max_lines = cfg.get("max_lines", DEFAULT_MAX_LINES) - - try: - source = Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_read_error"}, - ) - - if not source.strip(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[ - CheckItem( - name="file-size", - passed=True, - message="Empty file.", - severity=Severity.WARNING, - ) - ], - file_path=file_path, - ) - - total_lines, code_lines = _count_code_lines(source) - - if code_lines <= max_lines: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[ - CheckItem( - name="file-size", - passed=True, - message=f"File has {code_lines} code lines (within {max_lines} limit).", - severity=Severity.WARNING, - ) - ], - file_path=file_path, - metadata={"total_lines": total_lines, "code_lines": code_lines, "max_lines": max_lines}, - ) - - # File is too large - suggestions = _suggest_split_points(source) - overage = code_lines - max_lines - - return CheckResult( - plugin=PLUGIN_NAME, - passed=False, - checks=[ - CheckItem( - name="file-size", - passed=False, - message=f"File has {code_lines} code lines, exceeds limit of {max_lines} by {overage} lines", - severity=Severity.WARNING, - fix_hint=f"Consider breaking this file into smaller modules. {suggestions}", - ) - ], - file_path=file_path, - metadata={"total_lines": total_lines, "code_lines": code_lines, "max_lines": max_lines}, - ) diff --git a/src/seedgo/plugins/file_structure.py b/src/seedgo/plugins/file_structure.py deleted file mode 100644 index d4929bba..00000000 --- a/src/seedgo/plugins/file_structure.py +++ /dev/null @@ -1,294 +0,0 @@ -""" -Seed Go Plugin: file-structure - -Enforces directory conventions for Python projects. This is Seed Go's killer -feature — no traditional linter (ruff, pylint, flake8) checks WHERE files are -placed. They only check file contents. - -Checks performed: - a. Test files (test_*.py or *_test.py) should be in a tests/ directory, - not placed directly at the project root. - b. Python source files should not live directly at the project root - (except for well-known root-level files like setup.py, conftest.py). - c. Source packages (directories with .py files) should have __init__.py. - -Config example: - { - "plugins": { - "config": { - "file-structure": { - "allowed_root_files": ["setup.py", "conftest.py", "manage.py"] - } - } - } - } - -Note: This plugin receives one file at a time from the runner. It checks the -given file's placement relative to the project root, which it infers from the -path. For checks that require directory context (like __init__.py presence), -it inspects the file's parent directory. -""" - -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "file-structure" -PLUGIN_DESCRIPTION = "Enforce directory conventions: test placement, root files, package __init__.py." -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - -# Files commonly allowed at the project root -DEFAULT_ALLOWED_ROOT_FILES = [ - "setup.py", - "conftest.py", - "manage.py", - "wsgi.py", - "asgi.py", - "fabfile.py", - "noxfile.py", - "tasks.py", -] - -# Directories that are considered "test directories" — test files are OK here -TEST_DIR_NAMES = {"tests", "test", "testing", "spec"} - -# Directories that should never trigger root-file violations even if named differently -SKIP_DIR_NAMES = { - ".seedgo", - ".git", - "__pycache__", - "node_modules", - ".tox", - ".venv", - "venv", - "env", - ".eggs", - "dist", - "build", -} - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check a Python file's placement against project directory conventions. - - Inspects the file path relative to the inferred project root and flags - violations of standard Python project structure conventions. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional dict. Supported keys: - - "allowed_root_files": list[str] of filenames OK at root. - - Returns: - CheckResult with ERROR-severity items for structural violations. - """ - - cfg = config or {} - allowed_root_files: list[str] = cfg.get("allowed_root_files", DEFAULT_ALLOWED_ROOT_FILES) - - path = Path(file_path).resolve() - - # Skip files that don't exist (e.g., deleted during scan) - if not path.exists(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_not_found"}, - ) - - # Skip files in ignored directories - for part in path.parts: - if part in SKIP_DIR_NAMES: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "excluded_directory"}, - ) - - violations: list[CheckItem] = [] - file_name = path.name - parent_dir = path.parent - parent_name = parent_dir.name - - # ------------------------------------------------------------------- - # Infer project root: walk up to find a known project root marker. - # Markers: .seedgo/, setup.py, pyproject.toml, setup.cfg, .git/ - # ------------------------------------------------------------------- - project_root = _find_project_root(path) - - if project_root is None: - # Cannot determine project root — skip structural checks - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "no_project_root_found"}, - ) - - try: - rel_path = path.relative_to(project_root) - except ValueError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "path_outside_project_root"}, - ) - - rel_parts = rel_path.parts # e.g., ("src", "mypackage", "module.py") - is_at_root = len(rel_parts) == 1 # file is directly in project root - - # ------------------------------------------------------------------- - # Check A: Test files should be in a tests/ directory - # ------------------------------------------------------------------- - is_test_file = file_name.startswith("test_") or file_name.endswith("_test.py") - - if is_test_file: - # Check if ANY parent directory (relative to project root) is a test dir - parent_dirs = {part.lower() for part in rel_parts[:-1]} - in_test_dir = bool(parent_dirs & TEST_DIR_NAMES) - - if not in_test_dir: - violations.append(CheckItem( - name="test-file-placement", - passed=False, - message=( - f"Test file `{rel_path}` is not inside a tests/ directory. " - f"Test files should be grouped under tests/ for discoverability." - ), - severity=Severity.ERROR, - line=None, - fix_hint=f"Move `{file_name}` into a `tests/` directory.", - )) - - # ------------------------------------------------------------------- - # Check B: Non-special Python files should not be at project root - # ------------------------------------------------------------------- - if is_at_root and not is_test_file: - if file_name not in allowed_root_files and not file_name.startswith("_"): - violations.append(CheckItem( - name="root-python-file", - passed=False, - message=( - f"Python file `{file_name}` is at the project root. " - f"Source files should be inside a package directory (e.g., src/)." - ), - severity=Severity.ERROR, - line=None, - fix_hint=( - f"Move `{file_name}` into a source package directory, " - f"or add it to `allowed_root_files` in plugin config." - ), - )) - - # ------------------------------------------------------------------- - # Check C: Source packages should have __init__.py - # ------------------------------------------------------------------- - # Only check non-root, non-test, non-hidden directories - if not is_at_root and not is_test_file: - # Check if the file's parent directory is a Python package - # (i.e., contains .py files but no __init__.py) - if _is_missing_init(parent_dir, project_root): - violations.append(CheckItem( - name="missing-init-py", - passed=False, - message=( - f"Directory `{parent_name}/` contains Python files but has no `__init__.py`. " - f"Add `__init__.py` to make it a proper Python package." - ), - severity=Severity.ERROR, - line=None, - fix_hint=f"Create an empty `{parent_dir / '__init__.py'}` file.", - )) - - if violations: - passed = False - checks = violations - else: - passed = True - checks = [ - CheckItem( - name="file-structure", - passed=True, - message=f"File `{rel_path}` follows project structure conventions.", - severity=Severity.ERROR, - ) - ] - - return CheckResult( - plugin=PLUGIN_NAME, - passed=passed, - checks=checks, - file_path=file_path, - metadata={"violations_found": len(violations)}, - ) - - -def _find_project_root(file_path: Path) -> Path | None: - """Walk up from file_path to find the project root. - - Looks for markers: .seedgo/, .git/, pyproject.toml, setup.py, setup.cfg. - - Args: - file_path: Absolute path to a file within the project. - - Returns: - Path to the project root directory, or None if not found. - """ - markers = {".seedgo", ".git", "pyproject.toml", "setup.py", "setup.cfg"} - current = file_path.parent - - for directory in [current, *current.parents]: - for marker in markers: - if (directory / marker).exists(): - return directory - - return None - - -def _is_missing_init(directory: Path, project_root: Path) -> bool: - """Return True if a directory has Python files but no __init__.py. - - Only checks non-hidden, non-special directories that are inside the - project root (to avoid checking installed packages, virtualenvs, etc.). - - Args: - directory: Directory to check. - project_root: Project root — only check directories within this. - - Returns: - True if __init__.py is absent and the directory has .py source files. - """ - # Skip if directory is not within project root - try: - directory.relative_to(project_root) - except ValueError: - return False - - # Skip special/hidden/generated directories - dir_name = directory.name - if dir_name.startswith(".") or dir_name.startswith("_") or dir_name in SKIP_DIR_NAMES: - return False - - # Skip test directories — they typically don't need __init__.py - if dir_name.lower() in TEST_DIR_NAMES: - return False - - # Check for __init__.py - if (directory / "__init__.py").exists(): - return False - - # Check if there are any .py source files (that aren't __init__.py) - py_files = [ - f for f in directory.iterdir() - if f.is_file() and f.suffix == ".py" and f.name != "__init__.py" - ] - - return len(py_files) > 0 diff --git a/src/seedgo/plugins/function_length.py b/src/seedgo/plugins/function_length.py deleted file mode 100644 index 3d8903a6..00000000 --- a/src/seedgo/plugins/function_length.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Seed Go Plugin: function-length - -Flags functions that exceed a configurable maximum line count. Long functions -are harder to read, test, and maintain. Breaking them into smaller, focused -functions improves code quality significantly. - -This is a Seed Go differentiator: ruff has no function length rule. No -traditional linter checks this. Only architectural tools can enforce it. - -Default max_lines: 50 (configurable via plugin config) -Config example: - { - "plugins": { - "config": { - "function-length": {"max_lines": 40} - } - } - } - -Uses the `ast` module's end_lineno attribute (Python 3.8+) to get accurate -function boundaries including all nested code. -""" - -import ast -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "function-length" -PLUGIN_DESCRIPTION = "Flag functions exceeding a configurable maximum line count." -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - -DEFAULT_MAX_LINES = 50 - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check a Python file for functions exceeding the maximum line count. - - Uses ast.parse() and end_lineno to measure function length precisely, - including all nested statements, docstrings, and blank lines within the body. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional dict with key "max_lines" (int). Defaults to 50. - - Returns: - CheckResult with one WARNING CheckItem per oversized function found. - """ - - cfg = config or {} - max_lines: int = int(cfg.get("max_lines", DEFAULT_MAX_LINES)) - - try: - source = Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_read_error"}, - ) - - if not source.strip(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[CheckItem( - name="function-length", - passed=True, - message="Empty file — no functions to check.", - severity=Severity.WARNING, - )], - file_path=file_path, - ) - - try: - tree = ast.parse(source, filename=file_path) - except SyntaxError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "syntax_error"}, - ) - - violations: list[CheckItem] = [] - - for node in ast.walk(tree): - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - - start_line: int = node.lineno - # end_lineno is available from Python 3.8+ - end_line: int = getattr(node, "end_lineno", node.lineno) - func_lines: int = end_line - start_line + 1 - - if func_lines > max_lines: - func_name = node.name - violations.append( - CheckItem( - name="function-length", - passed=False, - message=( - f"Function `{func_name}` at line {start_line} " - f"is {func_lines} lines long (max: {max_lines})." - ), - severity=Severity.WARNING, - line=start_line, - fix_hint=( - f"Break `{func_name}` into smaller functions. " - f"Extract logical sections into helper functions." - ), - ) - ) - - if violations: - passed = False - checks = violations - else: - passed = True - checks = [ - CheckItem( - name="function-length", - passed=True, - message=f"All functions are within the {max_lines}-line limit.", - severity=Severity.WARNING, - ) - ] - - return CheckResult( - plugin=PLUGIN_NAME, - passed=passed, - checks=checks, - file_path=file_path, - metadata={ - "violations_found": len(violations), - "max_lines": max_lines, - }, - ) diff --git a/src/seedgo/plugins/naming_convention.py b/src/seedgo/plugins/naming_convention.py deleted file mode 100644 index e9cb57e9..00000000 --- a/src/seedgo/plugins/naming_convention.py +++ /dev/null @@ -1,267 +0,0 @@ -""" -Seed Go Plugin: naming-convention - -Enforces Python naming conventions: -- snake_case for functions, variables, and module names -- PascalCase for classes -- UPPER_CASE for module-level constants - -This is a fundamental Python style rule (PEP 8) that improves code readability -and consistency across projects. -""" - -import ast -import re -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "naming-convention" -PLUGIN_DESCRIPTION = "Enforce Python naming conventions (PEP 8)" -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - -# Naming patterns -_SNAKE_CASE_RE = re.compile(r"^[a-z_][a-z0-9_]*$") -_PASCAL_CASE_RE = re.compile(r"^[A-Z][a-zA-Z0-9]*$") -_UPPER_CASE_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") - -# Common exceptions that are acceptable -_COMMON_EXCEPTIONS = { - # Common single-letter variables - "i", "j", "k", "n", "x", "y", "z", "a", "b", "c", - # Common acronyms/names that are conventionally capitalized - "T", "K", "V", - # Special methods and variables - "_", "__", -} - - -def _is_constant(node: ast.Assign | ast.AnnAssign, scope: str) -> bool: - """Determine if an assignment is a module-level constant. - - Constants are defined as module-level assignments where: - 1. The name is in UPPER_CASE - 2. The assignment is at module level (not in a function/class) - - Args: - node: Assignment node - scope: "module", "class", or "function" - - Returns: - True if this appears to be a constant - """ - if scope != "module": - return False - - # Get target names - if isinstance(node, ast.Assign): - targets = node.targets - else: # AnnAssign - targets = [node.target] - - for target in targets: - if isinstance(target, ast.Name): - # Consider it a constant if it matches UPPER_CASE pattern - if _UPPER_CASE_RE.match(target.id): - return True - - return False - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check a Python file for naming convention violations. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional plugin config dict (unused by this plugin). - - Returns: - CheckResult with one CheckItem per naming violation found. - """ - _ = config # Part of plugin interface contract - - try: - source = Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_read_error"}, - ) - - if not source.strip(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[ - CheckItem( - name="naming-convention", - passed=True, - message="Empty file — no names to check.", - severity=Severity.WARNING, - ) - ], - file_path=file_path, - ) - - try: - tree = ast.parse(source, filename=file_path) - except SyntaxError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "syntax_error"}, - ) - - violations: list[CheckItem] = [] - - # Check module name (file name) - module_name = Path(file_path).stem - if module_name not in ("__init__", "__main__") and not _SNAKE_CASE_RE.match(module_name): - violations.append( - CheckItem( - name="module-name", - passed=False, - message=f"Module name '{module_name}' should be snake_case", - severity=Severity.WARNING, - fix_hint=f"Rename file to use snake_case (e.g., {_to_snake_case(module_name)}.py)", - ) - ) - - # Walk the AST and check naming - for node in ast.walk(tree): - # Check class names (should be PascalCase) - if isinstance(node, ast.ClassDef): - if not _PASCAL_CASE_RE.match(node.name) and node.name not in _COMMON_EXCEPTIONS: - violations.append( - CheckItem( - name="class-name", - passed=False, - message=f"Class '{node.name}' at line {node.lineno} should be PascalCase", - severity=Severity.WARNING, - line=node.lineno, - fix_hint=f"Rename to PascalCase (e.g., {_to_pascal_case(node.name)})", - ) - ) - - # Check function names (should be snake_case) - elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - # Skip dunder methods - if node.name.startswith("__") and node.name.endswith("__"): - continue - - if not _SNAKE_CASE_RE.match(node.name) and node.name not in _COMMON_EXCEPTIONS: - violations.append( - CheckItem( - name="function-name", - passed=False, - message=f"Function '{node.name}' at line {node.lineno} should be snake_case", - severity=Severity.WARNING, - line=node.lineno, - fix_hint=f"Rename to snake_case (e.g., {_to_snake_case(node.name)})", - ) - ) - - # Check module-level variables and constants - for node in tree.body: - if isinstance(node, (ast.Assign, ast.AnnAssign)): - # Determine if this is a constant or variable - is_const = _is_constant(node, "module") - - # Get target names - if isinstance(node, ast.Assign): - targets = node.targets - else: # AnnAssign - targets = [node.target] - - for target in targets: - if isinstance(target, ast.Name): - name = target.id - - # Skip special variables - if name.startswith("__") and name.endswith("__"): - continue - - if name in _COMMON_EXCEPTIONS: - continue - - # Check naming based on whether it's a constant - if is_const: - if not _UPPER_CASE_RE.match(name): - violations.append( - CheckItem( - name="constant-name", - passed=False, - message=f"Module-level constant '{name}' at line {node.lineno} should be UPPER_CASE", - severity=Severity.WARNING, - line=node.lineno, - fix_hint=f"Rename to UPPER_CASE (e.g., {name.upper()})", - ) - ) - else: - if not _SNAKE_CASE_RE.match(name): - violations.append( - CheckItem( - name="variable-name", - passed=False, - message=f"Module-level variable '{name}' at line {node.lineno} should be snake_case", - severity=Severity.WARNING, - line=node.lineno, - fix_hint=f"Rename to snake_case (e.g., {_to_snake_case(name)})", - ) - ) - - if violations: - passed = False - checks = violations - else: - passed = True - checks = [ - CheckItem( - name="naming-convention", - passed=True, - message="All names follow Python naming conventions (PEP 8).", - severity=Severity.WARNING, - ) - ] - - return CheckResult( - plugin=PLUGIN_NAME, - passed=passed, - checks=checks, - file_path=file_path, - metadata={"violations_found": len(violations)}, - ) - - -def _to_snake_case(name: str) -> str: - """Convert a name to snake_case suggestion. - - Args: - name: Original name - - Returns: - Suggested snake_case version - """ - # Simple conversion: insert underscores before uppercase letters - result = re.sub(r"(? str: - """Convert a name to PascalCase suggestion. - - Args: - name: Original name - - Returns: - Suggested PascalCase version - """ - # Split on underscores and capitalize each word - words = name.split("_") - return "".join(word.capitalize() for word in words if word) diff --git a/src/seedgo/plugins/no_bare_except.py b/src/seedgo/plugins/no_bare_except.py deleted file mode 100644 index 0363368a..00000000 --- a/src/seedgo/plugins/no_bare_except.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Seed Go Plugin: no-bare-except - -Flags bare `except:` clauses in Python files. Bare excepts swallow all -exceptions including KeyboardInterrupt and SystemExit, which can cause -programs to become unresponsive or hide serious errors. - -Correct usage: - except Exception: # catches all normal exceptions - except (ValueError, TypeError): # specific exceptions preferred - -Linters like ruff have this rule (E722), so this plugin demonstrates how -Seed Go can layer additional context and fixable hints on top of or alongside -traditional linter rules. -""" - -import re -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "no-bare-except" -PLUGIN_DESCRIPTION = "Flag bare except: clauses that swallow all exceptions." -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - -# Matches `except:` with nothing after the colon (bare except) -# Allows for trailing whitespace and inline comments -_BARE_EXCEPT_RE = re.compile(r"^\s*except\s*:\s*(#.*)?$") - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check a Python file for bare except: clauses. - - Parses the file line-by-line, tracking docstring/multiline string state - to avoid false positives from `except:` appearing inside string literals. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional plugin config dict (unused by this plugin). - - Returns: - CheckResult with one CheckItem per bare except found, plus a summary - item if none are found. - """ - _ = config # Part of plugin interface contract - - try: - source = Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError: - # Cannot read file — return passing result (don't block on I/O errors) - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_read_error"}, - ) - - violations: list[CheckItem] = [] - lines = source.splitlines() - - # Track whether we're inside a triple-quoted string to avoid false positives - in_triple_single = False - in_triple_double = False - - for lineno, raw_line in enumerate(lines, start=1): - # Simplified triple-quote tracking (handles common cases) - # Count unescaped triple quotes — toggle state - stripped = raw_line - - # Toggle triple-string state before checking for except - # Count occurrences of ''' and """ (not inside each other) - if not in_triple_double: - triple_single_count = stripped.count("'''") - if triple_single_count % 2 == 1: - in_triple_single = not in_triple_single - - if not in_triple_single: - triple_double_count = stripped.count('"""') - if triple_double_count % 2 == 1: - in_triple_double = not in_triple_double - - # Only check lines that are not inside a string literal - if in_triple_single or in_triple_double: - continue - - # Strip the line to check if it's a comment - stripped_line = raw_line.strip() - if stripped_line.startswith("#"): - continue - - if _BARE_EXCEPT_RE.match(raw_line): - violations.append( - CheckItem( - name="bare-except", - passed=False, - message=f"Bare `except:` at line {lineno} — catches ALL exceptions including KeyboardInterrupt.", - severity=Severity.WARNING, - line=lineno, - fix_hint="Replace with `except Exception:` or a more specific exception type.", - ) - ) - - if violations: - passed = False - checks = violations - else: - passed = True - checks = [ - CheckItem( - name="bare-except", - passed=True, - message="No bare except: clauses found.", - severity=Severity.WARNING, - ) - ] - - return CheckResult( - plugin=PLUGIN_NAME, - passed=passed, - checks=checks, - file_path=file_path, - metadata={"violations_found": len(violations)}, - ) diff --git a/src/seedgo/plugins/silent_failure_detection.py b/src/seedgo/plugins/silent_failure_detection.py deleted file mode 100644 index 5b53cd81..00000000 --- a/src/seedgo/plugins/silent_failure_detection.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -Seed Go Plugin: silent-failure-detection - -Detects silent failure patterns in Python code: -- except: pass (bare except with only pass) -- except Exception: pass (catches all exceptions and silently ignores) - -Silent failures hide errors and make debugging extremely difficult. They can -mask critical issues like KeyboardInterrupt, SystemExit, or legitimate bugs. - -Best practice: Always log exceptions or provide meaningful error handling. -If ignoring an exception is intentional, add a comment explaining why. -""" - -import ast -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "silent-failure-detection" -PLUGIN_DESCRIPTION = "Detect silent failure patterns (except: pass)" -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - - -class SilentFailureVisitor(ast.NodeVisitor): - """AST visitor that detects silent failure patterns in exception handlers. - - Identifies except blocks that only contain a pass statement, which silently - swallow exceptions without logging or handling them. - """ - - def __init__(self): - self.violations: list[CheckItem] = [] - - def visit_Try(self, node: ast.Try) -> None: - """Visit try/except blocks and check for silent failures. - - Args: - node: The Try node to check - """ - for handler in node.handlers: - if self._is_silent_failure(handler): - exception_type = self._get_exception_type_name(handler) - self.violations.append( - CheckItem( - name="silent-failure", - passed=False, - message=f"Silent failure at line {handler.lineno}: {exception_type} with only 'pass' — exceptions are hidden", - severity=Severity.ERROR, - line=handler.lineno, - fix_hint=self._get_fix_hint(exception_type), - ) - ) - - # Continue visiting child nodes - self.generic_visit(node) - - def _is_silent_failure(self, handler: ast.ExceptHandler) -> bool: - """Check if an exception handler is a silent failure. - - A silent failure is an except block that only contains a pass statement - (or is empty, which is treated as pass). - - Args: - handler: The exception handler to check - - Returns: - True if this is a silent failure pattern - """ - # Empty handler body or only pass statement - if not handler.body: - return True - - # Single pass statement - if len(handler.body) == 1 and isinstance(handler.body[0], ast.Pass): - return True - - # Multiple statements, but all are pass (unusual but possible) - if all(isinstance(stmt, ast.Pass) for stmt in handler.body): - return True - - return False - - def _get_exception_type_name(self, handler: ast.ExceptHandler) -> str: - """Extract the exception type name from a handler. - - Args: - handler: The exception handler - - Returns: - String representation of the exception type (e.g., "except:", "except Exception:") - """ - if handler.type is None: - return "except:" - - # Handle simple names like "Exception" - if isinstance(handler.type, ast.Name): - return f"except {handler.type.id}:" - - # Handle tuple of exceptions like (ValueError, TypeError) - if isinstance(handler.type, ast.Tuple): - names = [] - for elt in handler.type.elts: - if isinstance(elt, ast.Name): - names.append(elt.id) - if names: - return f"except ({', '.join(names)}):" - - # Fallback for complex types - return "except :" - - def _get_fix_hint(self, exception_type: str) -> str: - """Generate a fix hint based on the exception type. - - Args: - exception_type: The exception type string - - Returns: - Actionable fix hint - """ - if exception_type == "except:": - return ( - "Replace with 'except Exception:' and add logging or error handling. " - "If ignoring is intentional, add a comment explaining why." - ) - else: - return ( - "Add logging (e.g., logger.warning('Ignoring error', exc_info=True)) " - "or meaningful error handling. If ignoring is intentional, add a comment explaining why." - ) - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check a Python file for silent failure patterns. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional plugin config dict (unused by this plugin). - - Returns: - CheckResult with one CheckItem per silent failure found. - """ - _ = config # Part of plugin interface contract - - try: - source = Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_read_error"}, - ) - - if not source.strip(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[ - CheckItem( - name="silent-failure-detection", - passed=True, - message="Empty file — no code to check.", - severity=Severity.ERROR, - ) - ], - file_path=file_path, - ) - - try: - tree = ast.parse(source, filename=file_path) - except SyntaxError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "syntax_error"}, - ) - - # Use visitor pattern to find silent failures - visitor = SilentFailureVisitor() - visitor.visit(tree) - violations = visitor.violations - - if violations: - passed = False - checks = violations - else: - passed = True - checks = [ - CheckItem( - name="silent-failure-detection", - passed=True, - message="No silent failure patterns found.", - severity=Severity.ERROR, - ) - ] - - return CheckResult( - plugin=PLUGIN_NAME, - passed=passed, - checks=checks, - file_path=file_path, - metadata={"violations_found": len(violations)}, - ) diff --git a/src/seedgo/plugins/type_hints_required.py b/src/seedgo/plugins/type_hints_required.py deleted file mode 100644 index 8398074c..00000000 --- a/src/seedgo/plugins/type_hints_required.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -Seed Go Plugin: type-hints-required - -Checks that public functions have return type annotations. Type hints improve -IDE support, catch bugs early, and make code self-documenting. This plugin -targets the most impactful annotation: the return type. - -Skips: - - Private functions (starting with _ or __) - - __init__, __str__, __repr__, and other dunder methods - - Functions that are very short (< 2 lines) — likely trivial wrappers - -Uses the `ast` module for reliable parsing rather than fragile regex, so it -correctly handles multi-line signatures, decorators, and nested functions. -""" - -import ast -from pathlib import Path - -from seedgo.models import CheckItem, CheckResult, Severity - -PLUGIN_NAME = "type-hints-required" -PLUGIN_DESCRIPTION = "Public functions must have return type annotations." -FILE_TYPES = ["*.py"] -PLUGIN_VERSION = "1.0.0" - -# Dunder methods commonly exempted from return type requirements -_EXEMPT_DUNDERS = { - "__init__", - "__str__", - "__repr__", - "__len__", - "__bool__", - "__hash__", - "__del__", - "__enter__", - "__exit__", - "__iter__", - "__next__", - "__contains__", - "__getitem__", - "__setitem__", - "__delitem__", - "__call__", -} - - -def check(file_path: str, config: dict | None = None) -> CheckResult: - """Check a Python file for public functions missing return type annotations. - - Parses the file with the ast module and inspects all function definitions. - Only public, non-exempted functions are checked. - - Args: - file_path: Absolute path to the Python file to check. - config: Optional plugin config dict (unused by this plugin). - - Returns: - CheckResult with one CheckItem per violation found, or a passing item - if all public functions have return type annotations. - """ - _ = config # Part of plugin interface contract - - try: - source = Path(file_path).read_text(encoding="utf-8", errors="replace") - except OSError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "file_read_error"}, - ) - - if not source.strip(): - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[CheckItem( - name="type-hints-required", - passed=True, - message="Empty file — no functions to check.", - severity=Severity.WARNING, - )], - file_path=file_path, - ) - - try: - tree = ast.parse(source, filename=file_path) - except SyntaxError: - return CheckResult( - plugin=PLUGIN_NAME, - passed=True, - checks=[], - file_path=file_path, - metadata={"skipped": True, "reason": "syntax_error"}, - ) - - violations: list[CheckItem] = [] - - for node in ast.walk(tree): - if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - - func_name = node.name - - # Skip private functions (starting with _) - if func_name.startswith("_"): - continue - - # Skip exempt dunder methods - if func_name in _EXEMPT_DUNDERS: - continue - - # Skip very short functions (< 2 statements — likely trivial) - if len(node.body) < 2: - # Check if it's just a pass or docstring - if _is_trivial_body(node.body): - continue - - # Check for missing return annotation - if node.returns is None: - # Estimate function length from source lines - violations.append( - CheckItem( - name="type-hints-required", - passed=False, - message=( - f"Public function `{func_name}` at line {node.lineno} " - f"missing return type annotation." - ), - severity=Severity.WARNING, - line=node.lineno, - fix_hint=f"Add `-> ReturnType` before the colon: `def {func_name}(...) -> ReturnType:`", - ) - ) - - if violations: - passed = False - checks = violations - else: - passed = True - checks = [ - CheckItem( - name="type-hints-required", - passed=True, - message="All public functions have return type annotations.", - severity=Severity.WARNING, - ) - ] - - return CheckResult( - plugin=PLUGIN_NAME, - passed=passed, - checks=checks, - file_path=file_path, - metadata={"violations_found": len(violations)}, - ) - - -def _is_trivial_body(body: list) -> bool: - """Return True if a function body is trivially simple (pass, ellipsis, or docstring only).""" - if len(body) == 0: - return True - if len(body) == 1: - stmt = body[0] - # pass statement - if isinstance(stmt, ast.Pass): - return True - # ... (ellipsis) - if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant): - if stmt.value.value is ...: - return True - # String = docstring - if isinstance(stmt.value.value, str): - return True - return False diff --git a/src/seedgo/reporter.py b/src/seedgo/reporter.py deleted file mode 100644 index 9ed0d139..00000000 --- a/src/seedgo/reporter.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Seed Go Result Reporter - -Formats check results for display in three output modes: - - "human" — Rich-formatted terminal output using the display module. - Shows plugin names, pass/fail verdicts, scores, and per-check - items with severity markers. Summary line at the bottom. - - "json" — Machine-readable JSON dict. Includes all result data and the - overall summary. Safe for piping to other tools. - - "github" — GitHub Actions annotation format. Emits ::error and ::warning - annotation lines that GitHub renders inline on PR diffs. - -All formats are produced by a single entry point: report_results(). -""" - -import dataclasses -import json -from .models import CheckResult, Severity -from .display import ( - print_header, - print_plugin, - print_check_item, - print_summary, - print_counts, - print_separator, - print_no_results, -) - - -def report_results( - results: list[CheckResult], - overall: dict, - format: str = "human", -) -> str: - """Format check results for display. - - Dispatches to the appropriate formatter based on the format argument. - - Args: - results: List of CheckResult objects returned by run_checks(). - overall: Overall summary dict returned by run_checks() (from - calculate_overall()). Expected keys: overall_score, passed, - threshold, plugins_passed, plugins_failed, error_count, - warning_count, info_count. - format: Output format. One of: - "human" — Rich terminal output (default). Prints directly, - returns empty string. - "json" — JSON-encoded string, machine-readable. - "github" — GitHub Actions annotation lines. - - Returns: - Formatted string ready to print or write to stdout. - For "human" format, prints directly and returns empty string. - - Raises: - ValueError: If format is not one of the three supported values. - """ - if format == "human": - _format_human(results, overall) - return "" - elif format == "json": - return _format_json(results, overall) - elif format == "github": - return _format_github(results, overall) - else: - raise ValueError(f"Unknown format {format!r}. Choose: human, json, github") - - -# --------------------------------------------------------------------------- -# Human formatter -# --------------------------------------------------------------------------- - - -def _format_human(results: list[CheckResult], overall: dict) -> None: - """Print Rich-formatted terminal output for human consumption. - - Prints directly to the Rich console via the display module. - - Layout: - ╭─ SEEDGO ─────────────────────────────╮ - │ Code Standards Check │ - │ 5 plugins · 3 files · threshold: 75 │ - ╰───────────────────────────────────────╯ - - ✓ plugin-name file.py ········· PASS (100/100) - ✓ check-name: message - ✗ another-plugin file.py ······ FAIL (60/100) - ✗ check-name: message [line N] - hint: fix_hint text - - ───────────────────────────────────────── - Overall: 80/100 — PASS (threshold: 75) - 2 checks ran, 1 passed, 1 failed - """ - score = overall.get("overall_score", 100) - threshold = overall.get("threshold", 75) - passed = overall.get("passed", True) - p_passed = overall.get("plugins_passed", 0) - p_failed = overall.get("plugins_failed", 0) - - # Header - total_plugins = p_passed + p_failed - subtitle = f"{total_plugins} plugin(s) · threshold: {threshold}" - print_header("SEEDGO — Code Standards Check", subtitle) - - if not results: - print_no_results() - print_separator() - print_summary(score, passed, threshold) - return - - for result in results: - print_plugin(result.plugin, result.file_path, result.passed, result.score) - for item in result.checks: - print_check_item( - item.name, item.passed, item.message, item.severity, - line=item.line, fix_hint=item.fix_hint, - ) - - print_separator() - print_summary(score, passed, threshold) - print_counts( - p_passed, p_failed, - error_count=overall.get("error_count", 0), - warning_count=overall.get("warning_count", 0), - ) - - -# --------------------------------------------------------------------------- -# JSON formatter -# --------------------------------------------------------------------------- - - -def _format_json(results: list[CheckResult], overall: dict) -> str: - """Produce a JSON string containing all results and the overall summary. - - The JSON structure is: - { - "overall_score": int, - "passed": bool, - "threshold": int, - "plugins_passed": int, - "plugins_failed": int, - "error_count": int, - "warning_count": int, - "info_count": int, - "results": [ - { - "plugin": str, - "passed": bool, - "score": int, - "file_path": str, - "checks": [...], - "metadata": {...} - }, - ... - ] - } - """ - serializable_results = [] - for result in results: - d = dataclasses.asdict(result) - # Convert Severity enum values to plain strings for JSON - for check in d.get("checks", []): - if isinstance(check.get("severity"), Severity): - check["severity"] = check["severity"].value - elif hasattr(check.get("severity"), "value"): - check["severity"] = check["severity"].value - serializable_results.append(d) - - payload = dict(overall) - payload["results"] = serializable_results - - return json.dumps(payload, indent=2) - - -# --------------------------------------------------------------------------- -# GitHub Actions formatter -# --------------------------------------------------------------------------- - - -def _format_github(results: list[CheckResult], overall: dict) -> str: # noqa: ARG001 - """Produce GitHub Actions workflow annotation lines. - - Each failed check item becomes an annotation: - ::error file=path/to/file.py,line=42::message [plugin-name] - ::warning file=path/to/file.py,line=42::message [plugin-name] - ::notice file=path/to/file.py::message [plugin-name] - - INFO items are emitted as ::notice. Passed items are omitted. - - See: https://docs.github.com/en/actions/writing-workflows/ - choosing-what-your-workflow-does/workflow-commands-for-github-actions - """ - lines: list[str] = [] - - for result in results: - for item in result.checks: - if item.passed: - continue # Skip passing checks — no annotation needed - - file_part = f"file={result.file_path}" if result.file_path else "" - line_part = f",line={item.line}" if item.line is not None else "" - location = f"{file_part}{line_part}" - location_prefix = f"{location}::" if location else "" - - message = f"{item.message} [{result.plugin}]" - - if item.severity == Severity.ERROR: - lines.append(f"::error {location_prefix}{message}") - elif item.severity == Severity.WARNING: - lines.append(f"::warning {location_prefix}{message}") - else: - lines.append(f"::notice {location_prefix}{message}") - - return "\n".join(lines) diff --git a/src/seedgo/runner.py b/src/seedgo/runner.py deleted file mode 100644 index bf864bf4..00000000 --- a/src/seedgo/runner.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -Seed Go Check Runner - -Orchestrates the full check pipeline: plugin discovery, file discovery, -check execution, and score calculation. - -This module ties all Phase 1 components together: - - discover_plugins() — finds all plugins from all sources - - load_config() — loads and resolves .seedgo/config.json - - File walking — finds target files matching plugin FILE_TYPES, - respecting config paths.include/exclude - - Plugin execution — calls plugin.check(file_path, plugin_config) per match - - Scoring — severity-weighted score calculation (see calculate_score) - -Severity-weighted scoring model: - - error_weight (default 1.0) — full score deduction - - warning_weight (default 0.5) — half score deduction - - info_weight (default 0.0) — no score deduction (informational) - - score = (passed_weight / total_weight) * 100 - - Any unresolved ERROR blocks pass regardless of score -""" - -import fnmatch -import os -from pathlib import Path -from typing import Optional - -from .config import load_config -from .discovery import discover_plugins -from .models import CheckResult, Severity - - -def run_checks( - project_root: str, - files: Optional[list[str]] = None, - plugins: Optional[list[str]] = None, -) -> tuple[list["CheckResult"], dict]: - """Run all applicable checks against project files. - - Main orchestration function. Discovers plugins, finds target files, - executes checks, and returns results with an overall summary. - - Args: - project_root: Absolute path to the project root (must contain .seedgo/). - files: Optional list of specific file paths to check. If None or empty, - discovers all files under project_root matching plugin FILE_TYPES - and config paths.include/exclude rules. - plugins: Optional list of plugin names to run. If None or empty, runs - all discovered plugins (respecting config enabled/disabled). - - Returns: - A tuple of: - - list[CheckResult]: One result per (file, plugin) pair that ran. - - dict: Overall summary from calculate_overall(). Keys: - overall_score, passed, threshold, plugins_passed, plugins_failed, - error_count, warning_count, info_count. - """ - discovered = discover_plugins(project_root) - config = load_config(project_root) - - # Apply enabled/disabled plugin filters from config - enabled_set = set(config.get("plugins", {}).get("enabled", [])) - disabled_set = set(config.get("plugins", {}).get("disabled", [])) - plugin_configs = config.get("plugins", {}).get("config", {}) - - # Filter discovered plugins - active_plugins = [] - for plugin_info in discovered: - name = plugin_info["name"] - # Respect CLI plugin filter - if plugins and name not in plugins: - continue - # Respect config enabled list (if non-empty, only those are active) - if enabled_set and name not in enabled_set: - continue - # Respect config disabled list - if name in disabled_set: - continue - active_plugins.append(plugin_info) - - # Determine target files - if files: - target_files = [str(Path(f).resolve()) for f in files] - else: - target_files = _find_project_files(project_root, config) - - scoring_config = config.get("scoring", {}) - all_results: list[CheckResult] = [] - - for file_path in target_files: - for plugin_info in active_plugins: - module = plugin_info["module"] - file_types = getattr(module, "FILE_TYPES", ["*"]) - - # Only run plugin on files matching its FILE_TYPES patterns - if not _file_matches_types(file_path, file_types): - continue - - plugin_cfg = plugin_configs.get(plugin_info["name"], {}) - - try: - result = module.check(file_path, plugin_cfg or None) - # Calculate severity-weighted score - result.score = calculate_score(result, scoring_config) - all_results.append(result) - except Exception as exc: - # Plugin crashed — record as failed result, don't propagate - all_results.append(CheckResult( - plugin=plugin_info["name"], - passed=False, - checks=[], - score=0, - file_path=file_path, - metadata={"error": str(exc)}, - )) - - overall = calculate_overall(all_results, scoring_config) - return all_results, overall - - -def calculate_score(result: "CheckResult", config: dict) -> int: - """Calculate a severity-weighted score for a single plugin result. - - Score formula: - score = (passed_weight / total_weight) * 100 - - Where each CheckItem contributes its severity's weight to the total. - If the item passed, its weight also goes to passed_weight. - - Weights (from config or defaults): - error_weight: 1.0 — full deduction - warning_weight: 0.5 — half deduction - info_weight: 0.0 — no deduction (informational only) - - Special cases: - - No checks → score = 100 (no checks means no violations) - - total_weight == 0 → score = 100 (all checks are INFO-level only) - - Args: - result: A CheckResult from a plugin's check() call. - config: Scoring config dict. Typically config["scoring"] from load_config(). - Keys: error_weight, warning_weight, info_weight. - - Returns: - Integer score from 0 to 100 (inclusive). - """ - if not result.checks: - return 100 - - error_weight: float = config.get("error_weight", 1.0) - warning_weight: float = config.get("warning_weight", 0.5) - info_weight: float = config.get("info_weight", 0.0) - - weight_map = { - Severity.ERROR: error_weight, - Severity.WARNING: warning_weight, - Severity.INFO: info_weight, - } - - total_weight: float = 0.0 - passed_weight: float = 0.0 - - for check_item in result.checks: - w = weight_map.get(check_item.severity, error_weight) - total_weight += w - if check_item.passed: - passed_weight += w - - if total_weight == 0.0: - return 100 - - return int((passed_weight / total_weight) * 100) - - -def calculate_overall(results: list["CheckResult"], config: dict) -> dict: - """Aggregate per-plugin scores into a single overall summary. - - Pass condition: overall_score >= threshold AND error_count == 0. - A single unresolved ERROR prevents passing regardless of score. - - Args: - results: List of CheckResult objects (may span multiple files/plugins). - config: Scoring config dict. Typically config["scoring"] from load_config(). - Keys used: threshold, error_weight, warning_weight, info_weight. - - Returns: - Dict with keys: - overall_score (int): Mean of per-result scores (0-100). - passed (bool): True if score >= threshold and no ERRORs. - threshold (int): Pass threshold from config (default 75). - plugins_passed (int): Count of results with score >= threshold. - plugins_failed (int): Count of results with score < threshold. - error_count (int): Unresolved ERROR check items across all results. - warning_count (int): Unresolved WARNING check items across all results. - info_count (int): Unresolved INFO check items across all results. - """ - threshold: int = int(config.get("threshold", 75)) - - if not results: - return { - "overall_score": 100, - "passed": True, - "threshold": threshold, - "plugins_passed": 0, - "plugins_failed": 0, - "error_count": 0, - "warning_count": 0, - "info_count": 0, - } - - # Recalculate scores for all results using the provided config - scores = [calculate_score(r, config) for r in results] - overall_score = int(sum(scores) / len(scores)) - - # Count unresolved (failed) check items by severity - error_count = sum( - 1 for r in results for c in r.checks - if not c.passed and c.severity == Severity.ERROR - ) - warning_count = sum( - 1 for r in results for c in r.checks - if not c.passed and c.severity == Severity.WARNING - ) - info_count = sum( - 1 for r in results for c in r.checks - if not c.passed and c.severity == Severity.INFO - ) - - # Pass requires score above threshold AND zero unresolved errors - passed = overall_score >= threshold and error_count == 0 - - plugins_passed = sum(1 for s in scores if s >= threshold) - plugins_failed = sum(1 for s in scores if s < threshold) - - return { - "overall_score": overall_score, - "passed": passed, - "threshold": threshold, - "plugins_passed": plugins_passed, - "plugins_failed": plugins_failed, - "error_count": error_count, - "warning_count": warning_count, - "info_count": info_count, - } - - -def _find_project_files(project_root: str, config: dict) -> list[str]: - """Walk the project directory and return files matching include/exclude config. - - Respects config paths.include (list of paths/globs relative to project_root) - and config paths.exclude (list of paths/globs to skip). - - Args: - project_root: Absolute path to the project root. - config: Fully resolved config dict from load_config(). - - Returns: - Sorted list of absolute file path strings. - """ - root = Path(project_root).resolve() - paths_config = config.get("paths", {}) - include_patterns: list[str] = paths_config.get("include", ["."]) - exclude_patterns: list[str] = paths_config.get("exclude", []) - - collected: set[str] = set() - - for include in include_patterns: - include_path = root / include - # Normalise: if the include pattern resolves to a directory, walk it. - # Otherwise treat it as a glob from the root. - if include_path.is_dir(): - for dirpath, _, filenames in os.walk(include_path): - for fname in filenames: - fp = Path(dirpath) / fname - collected.add(str(fp.resolve())) - else: - # Try as a glob pattern relative to root - for fp in root.glob(include): - if fp.is_file(): - collected.add(str(fp.resolve())) - - # Apply exclude patterns — match against relative paths - result: list[str] = [] - for file_path in sorted(collected): - try: - rel = str(Path(file_path).relative_to(root)) - except ValueError: - rel = file_path - - excluded = False - for pattern in exclude_patterns: - # Match against relative path or just the filename - if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(os.path.basename(rel), pattern): - excluded = True - break - # Also handle directory prefixes: "tests/" should exclude "tests/foo.py" - if rel.startswith(pattern.rstrip("/") + "/") or rel.startswith(pattern): - excluded = True - break - - if not excluded: - result.append(file_path) - - return result - - -def _file_matches_types(file_path: str, file_types: list[str]) -> bool: - """Check whether a file path matches any of the plugin's FILE_TYPES patterns. - - Uses fnmatch glob semantics against the file's basename. - A pattern of "*" matches any file. - - Args: - file_path: Absolute or relative path to the file. - file_types: List of glob patterns from the plugin's FILE_TYPES constant - (e.g., ["*.py"], ["*.js", "*.ts"]). - - Returns: - True if the file matches at least one pattern. - """ - name = os.path.basename(file_path) - for pattern in file_types: - if fnmatch.fnmatch(name, pattern): - return True - return False diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..44336b40 --- /dev/null +++ b/uv.lock @@ -0,0 +1,369 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "aipass" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "rich" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +trinity = [ + { name = "trinity-pattern" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-cov", marker = "extra == 'dev'" }, + { name = "rich", specifier = ">=13.0" }, + { name = "ruff", marker = "extra == 'dev'" }, + { name = "trinity-pattern", marker = "extra == 'trinity'", specifier = ">=1.0.0" }, +] +provides-extras = ["trinity", "seedgo", "dev"] + +[[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.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d4/7827d9ffa34d5d4d752eec907022aa417120936282fc488306f5da08c292/coverage-7.13.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fc31c787a84f8cd6027eba44010517020e0d18487064cd3d8968941856d1415", size = 219152, upload-time = "2026-02-09T12:56:11.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/d69df26607c64043292644dbb9dc54b0856fabaa2cbb1eeee3331cc9e280/coverage-7.13.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a32ebc02a1805adf637fc8dec324b5cdacd2e493515424f70ee33799573d661b", size = 219667, upload-time = "2026-02-09T12:56:13.33Z" }, + { url = "https://files.pythonhosted.org/packages/82/a4/c1523f7c9e47b2271dbf8c2a097e7a1f89ef0d66f5840bb59b7e8814157b/coverage-7.13.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e24f9156097ff9dc286f2f913df3a7f63c0e333dcafa3c196f2c18b4175ca09a", size = 246425, upload-time = "2026-02-09T12:56:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/aa7ec01d1a5023c4b680ab7257f9bfde9defe8fdddfe40be096ac19e8177/coverage-7.13.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8041b6c5bfdc03257666e9881d33b1abc88daccaf73f7b6340fb7946655cd10f", size = 248229, upload-time = "2026-02-09T12:56:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/98/85aba0aed5126d896162087ef3f0e789a225697245256fc6181b95f47207/coverage-7.13.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a09cfa6a5862bc2fc6ca7c3def5b2926194a56b8ab78ffcf617d28911123012", size = 250106, upload-time = "2026-02-09T12:56:18.024Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1db59bd67494bc162e3e4cd5fbc7edba2c7026b22f7c8ef1496d58c2b94c/coverage-7.13.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:296f8b0af861d3970c2a4d8c91d48eb4dd4771bcef9baedec6a9b515d7de3def", size = 252021, upload-time = "2026-02-09T12:56:19.272Z" }, + { url = "https://files.pythonhosted.org/packages/9d/97/72899c59c7066961de6e3daa142d459d47d104956db43e057e034f015c8a/coverage-7.13.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e101609bcbbfb04605ea1027b10dc3735c094d12d40826a60f897b98b1c30256", size = 247114, upload-time = "2026-02-09T12:56:21.051Z" }, + { url = "https://files.pythonhosted.org/packages/39/1f/f1885573b5970235e908da4389176936c8933e86cb316b9620aab1585fa2/coverage-7.13.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aa3feb8db2e87ff5e6d00d7e1480ae241876286691265657b500886c98f38bda", size = 248143, upload-time = "2026-02-09T12:56:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/e80390c5b7480b722fa3e994f8202807799b85bc562aa4f1dde209fbb7be/coverage-7.13.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4fc7fa81bbaf5a02801b65346c8b3e657f1d93763e58c0abdf7c992addd81a92", size = 246152, upload-time = "2026-02-09T12:56:23.748Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/f89a8350d85572f95412debb0fb9bb4795b1d5b5232bd652923c759e787b/coverage-7.13.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:33901f604424145c6e9c2398684b92e176c0b12df77d52db81c20abd48c3794c", size = 249959, upload-time = "2026-02-09T12:56:25.209Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/612a02aece8178c818df273e8d1642190c4875402ca2ba74514394b27aba/coverage-7.13.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bb28c0f2cf2782508a40cec377935829d5fcc3ad9a3681375af4e84eb34b6b58", size = 246416, upload-time = "2026-02-09T12:56:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5afc39af67c2fa6786b03c3a7091fc300947387ce8914b096db8a73d67a/coverage-7.13.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d107aff57a83222ddbd8d9ee705ede2af2cc926608b57abed8ef96b50b7e8f9", size = 247025, upload-time = "2026-02-09T12:56:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/51/30/2bba8ef0682d5bd210c38fe497e12a06c9f8d663f7025e9f5c2c31ce847d/coverage-7.13.4-cp310-cp310-win32.whl", hash = "sha256:a6f94a7d00eb18f1b6d403c91a88fd58cfc92d4b16080dfdb774afc8294469bf", size = 221758, upload-time = "2026-02-09T12:56:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/331f94934cf6c092b8ea59ff868eb587bc8fe0893f02c55bc6c0183a192e/coverage-7.13.4-cp310-cp310-win_amd64.whl", hash = "sha256:2cb0f1e000ebc419632bbe04366a8990b6e32c4e0b51543a6484ffe15eaeda95", size = 222693, upload-time = "2026-02-09T12:56:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" }, + { url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" }, + { url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" }, + { url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" }, + { url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" }, + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[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 = "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 = "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 = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +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 = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/31/d6e536cdebb6568ae75a7f00e4b4819ae0ad2640c3604c305a0428680b0c/ruff-0.15.4.tar.gz", hash = "sha256:3412195319e42d634470cc97aa9803d07e9d5c9223b99bcb1518f0c725f26ae1", size = 4569550, upload-time = "2026-02-26T20:04:14.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/82/c11a03cfec3a4d26a0ea1e571f0f44be5993b923f905eeddfc397c13d360/ruff-0.15.4-py3-none-linux_armv6l.whl", hash = "sha256:a1810931c41606c686bae8b5b9a8072adac2f611bb433c0ba476acba17a332e0", size = 10453333, upload-time = "2026-02-26T20:04:20.093Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/6a1f271f6e31dffb31855996493641edc3eef8077b883eaf007a2f1c2976/ruff-0.15.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5a1632c66672b8b4d3e1d1782859e98d6e0b4e70829530666644286600a33992", size = 10853356, upload-time = "2026-02-26T20:04:05.808Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d8/0fab9f8842b83b1a9c2bf81b85063f65e93fb512e60effa95b0be49bfc54/ruff-0.15.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a4386ba2cd6c0f4ff75252845906acc7c7c8e1ac567b7bc3d373686ac8c222ba", size = 10187434, upload-time = "2026-02-26T20:03:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/85/cc/cc220fd9394eff5db8d94dec199eec56dd6c9f3651d8869d024867a91030/ruff-0.15.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2496488bdfd3732747558b6f95ae427ff066d1fcd054daf75f5a50674411e75", size = 10535456, upload-time = "2026-02-26T20:03:52.738Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0f/bced38fa5cf24373ec767713c8e4cadc90247f3863605fb030e597878661/ruff-0.15.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f1c4893841ff2d54cbda1b2860fa3260173df5ddd7b95d370186f8a5e66a4ac", size = 10287772, upload-time = "2026-02-26T20:04:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2b/90/58a1802d84fed15f8f281925b21ab3cecd813bde52a8ca033a4de8ab0e7a/ruff-0.15.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:820b8766bd65503b6c30aaa6331e8ef3a6e564f7999c844e9a547c40179e440a", size = 11049051, upload-time = "2026-02-26T20:04:03.53Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ac/b7ad36703c35f3866584564dc15f12f91cb1a26a897dc2fd13d7cb3ae1af/ruff-0.15.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9fb74bab47139c1751f900f857fa503987253c3ef89129b24ed375e72873e85", size = 11890494, upload-time = "2026-02-26T20:04:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/3eb2f47a39a8b0da99faf9c54d3eb24720add1e886a5309d4d1be73a6380/ruff-0.15.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f80c98765949c518142b3a50a5db89343aa90f2c2bf7799de9986498ae6176db", size = 11326221, upload-time = "2026-02-26T20:04:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/90/bf134f4c1e5243e62690e09d63c55df948a74084c8ac3e48a88468314da6/ruff-0.15.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451a2e224151729b3b6c9ffb36aed9091b2996fe4bdbd11f47e27d8f2e8888ec", size = 11168459, upload-time = "2026-02-26T20:04:00.969Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/a64d27688789b06b5d55162aafc32059bb8c989c61a5139a36e1368285eb/ruff-0.15.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a8f157f2e583c513c4f5f896163a93198297371f34c04220daf40d133fdd4f7f", size = 11104366, upload-time = "2026-02-26T20:03:48.099Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f6/32d1dcb66a2559763fc3027bdd65836cad9eb09d90f2ed6a63d8e9252b02/ruff-0.15.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:917cc68503357021f541e69b35361c99387cdbbf99bd0ea4aa6f28ca99ff5338", size = 10510887, upload-time = "2026-02-26T20:03:45.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/92/22d1ced50971c5b6433aed166fcef8c9343f567a94cf2b9d9089f6aa80fe/ruff-0.15.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e9737c8161da79fd7cfec19f1e35620375bd8b2a50c3e77fa3d2c16f574105cc", size = 10285939, upload-time = "2026-02-26T20:04:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/7c20aec3143837641a02509a4668fb146a642fd1211846634edc17eb5563/ruff-0.15.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:291258c917539e18f6ba40482fe31d6f5ac023994ee11d7bdafd716f2aab8a68", size = 10765471, upload-time = "2026-02-26T20:03:58.924Z" }, + { url = "https://files.pythonhosted.org/packages/d0/09/6d2f7586f09a16120aebdff8f64d962d7c4348313c77ebb29c566cefc357/ruff-0.15.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3f83c45911da6f2cd5936c436cf86b9f09f09165f033a99dcf7477e34041cbc3", size = 11263382, upload-time = "2026-02-26T20:04:24.424Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fa/2ef715a1cd329ef47c1a050e10dee91a9054b7ce2fcfdd6a06d139afb7ec/ruff-0.15.4-py3-none-win32.whl", hash = "sha256:65594a2d557d4ee9f02834fcdf0a28daa8b3b9f6cb2cb93846025a36db47ef22", size = 10506664, upload-time = "2026-02-26T20:03:50.56Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a8/c688ef7e29983976820d18710f955751d9f4d4eb69df658af3d006e2ba3e/ruff-0.15.4-py3-none-win_amd64.whl", hash = "sha256:04196ad44f0df220c2ece5b0e959c2f37c777375ec744397d21d15b50a75264f", size = 11651048, upload-time = "2026-02-26T20:04:17.191Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, +] + +[[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 = "trinity-pattern" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/41/321329ffd84e848e2af4b86103aa1db7e74868fcc44afee41584107775d3/trinity_pattern-1.0.0.tar.gz", hash = "sha256:4525bf0d581af775ea1746df7190a8685d381086b8ccc379f6570201eae144e9", size = 23736, upload-time = "2026-02-25T19:42:46.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/2e/5620271e1166885edddd8924a8b5a44611af2d4272f3614a9709e3d5bb69/trinity_pattern-1.0.0-py3-none-any.whl", hash = "sha256:789895bf728b09de8b692ee5eb71fee95ab84a926db237e8163b9cd9ff4afe9e", size = 16970, upload-time = "2026-02-25T19:42:44.76Z" }, +] + +[[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" }, +]