init commit
This commit is contained in:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Testing / coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# uv
|
||||||
|
.python-version
|
||||||
141
CLAUDE.md
Normal file
141
CLAUDE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**Purpose:** This project is a privacy-first, locally-running Python coding agent — similar in spirit to `ollama-code` / Claude Code — that uses a local LLM (via Ollama or compatible OpenAI-spec endpoint) to perform autonomous coding tasks inside a project directory.
|
||||||
|
|
||||||
|
**Technologies:** Python 3.11+, Ollama (local LLM backend), Rich (terminal UI), Pydantic, YAML config
|
||||||
|
|
||||||
|
**Description:** A single-purpose interactive agent script that accepts natural language tasks and autonomously executes them using a defined toolset for filesystem operations, shell execution, code search, and file manipulation. The agent runs a tool-call loop: it sends the user task and conversation history to the LLM, receives tool calls, executes them safely, and feeds results back until the task is complete or the agent yields control. It is intentionally scoped — not a full Claude Code replacement, but a capable local alternative for day-to-day coding workflows.
|
||||||
|
|
||||||
|
**Inspiration:** [ollama-code](https://github.com/tcsenpai/ollama-code) (itself a fork of qwen-code / Gemini CLI), adapted to a clean Python architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
project-root/
|
||||||
|
├── app/
|
||||||
|
│ ├── agent/ # Core agent loop and orchestration
|
||||||
|
│ │ ├── loop.py # Main ReAct-style tool-call loop
|
||||||
|
│ │ └── context.py # Conversation history and session state
|
||||||
|
│ ├── tools/ # All agent-callable tools (one file per tool or group)
|
||||||
|
│ │ ├── base.py # BaseTool ABC and ToolResult dataclass
|
||||||
|
│ │ ├── filesystem.py # read_file, write_file, list_dir, make_dir, delete_file
|
||||||
|
│ │ ├── shell.py # run_command (with sandboxing/allow-list)
|
||||||
|
│ │ ├── search.py # grep_files, find_files
|
||||||
|
│ │ ├── edit.py # str_replace, patch_apply
|
||||||
|
│ │ └── registry.py # Tool registration and schema export
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── llm.py # LLM client wrapper (Ollama / OpenAI-spec)
|
||||||
|
│ │ ├── streaming.py # Streaming response handler
|
||||||
|
│ │ └── permissions.py # Approval gating for destructive operations
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── config.py # Pydantic config model
|
||||||
|
│ │ ├── tool_call.py # ToolCall / ToolResult schemas
|
||||||
|
│ │ └── message.py # Message schema (role, content, tool_calls)
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ ├── logging.py # Centralized logger (Rich handler)
|
||||||
|
│ │ ├── display.py # Rich console output helpers
|
||||||
|
│ │ ├── file_helpers.py # Safe path resolution, binary detection, size guards
|
||||||
|
│ │ └── token_counter.py# Approximate token usage tracking
|
||||||
|
│ └── main.py # Entrypoint — arg parsing, config load, agent kickoff
|
||||||
|
├── config/
|
||||||
|
│ └── config.yaml # YAML-driven config (model, endpoint, tool permissions, limits)
|
||||||
|
├── docs/
|
||||||
|
│ ├── code_guidelines.md
|
||||||
|
│ ├── security.md
|
||||||
|
│ └── tools.md # Tool spec and usage reference
|
||||||
|
├── tests/
|
||||||
|
│ ├── unit/
|
||||||
|
│ └── integration/
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── pyproject.toml
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
I prefer source in `app/`, not `src/`. Common logic (logging, HTTP, validators) is centralized. Structure is modular with services, utils, models, and tools as distinct layers. Typed config loaders and YAML-driven config structures are preferred.
|
||||||
|
|
||||||
|
### Coding Standards
|
||||||
|
|
||||||
|
I follow a **brainstorm → design → code → revise** flow and like maintaining that pattern across sessions.
|
||||||
|
|
||||||
|
Reference `docs/code_guidelines.md` for full code standards and rules. Key highlights:
|
||||||
|
|
||||||
|
- All functions and classes must have type annotations
|
||||||
|
- Pydantic v2 for all data models and config validation
|
||||||
|
- Tool implementations must inherit from `BaseTool` and return `ToolResult`
|
||||||
|
- Tool schemas must be auto-generated from docstrings + type hints for LLM consumption
|
||||||
|
- Async-first where appropriate (especially LLM streaming)
|
||||||
|
- No raw string path manipulation — use `pathlib.Path` throughout
|
||||||
|
- All file operations must go through `file_helpers.py` safe wrappers (path traversal guards, binary detection, size limits)
|
||||||
|
- Shell commands must go through the permissions service before execution
|
||||||
|
|
||||||
|
### Security Standards
|
||||||
|
|
||||||
|
Reference `docs/security.md` for full security guidance. Key highlights for this project:
|
||||||
|
|
||||||
|
- **No shell passthrough without approval gating** — destructive or side-effectful commands require explicit user confirmation
|
||||||
|
- **Path traversal protection** — all file operations must be validated against a configurable `workspace_root`
|
||||||
|
- **No secrets in config.yaml** — API keys (if ever needed) go in `.env`, never committed
|
||||||
|
- **Tool allow/deny lists** — configurable per-tool enable/disable in `config.yaml`
|
||||||
|
- **LLM response validation** — tool call arguments must be validated against schema before execution; never `eval()` LLM output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ CLAUDE WORKSPACE BELOW ⚠️
|
||||||
|
|
||||||
|
**The sections above define my development preferences and standards.**
|
||||||
|
**Everything below this line is working context for Claude to track project-specific information, decisions, and progress.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude Context & Project Notes
|
||||||
|
|
||||||
|
### Project Context
|
||||||
|
|
||||||
|
<!-- Architectural decisions, tech stack choices specific to THIS project -->
|
||||||
|
|
||||||
|
- **Agent pattern:** ReAct-style loop (Reason + Act). LLM emits tool calls; agent executes and feeds results back in the next turn. Loop terminates on a plain-text response with no tool calls (task complete) or explicit `finish` tool call.
|
||||||
|
- **LLM backend:** Ollama with OpenAI-compatible `/v1/chat/completions` endpoint. `llm.py` should abstract the client so swapping to another local backend is a config change, not a code change.
|
||||||
|
- **Tool call format:** Follow OpenAI function-calling spec (`tools` parameter with JSON schema). Models that support this natively (e.g., `qwen2.5-coder`, `llama3.1`, `mistral-nemo`) are preferred targets.
|
||||||
|
- **Core tools to implement first:** `read_file`, `write_file`, `list_dir`, `str_replace`, `run_command`, `grep_files`, `find_files`
|
||||||
|
- **Streaming:** Stream LLM output to terminal using Rich live display; tool calls are parsed from the accumulated stream before execution.
|
||||||
|
- **Permissions model:** Two-tier — auto-approve reads and searches; prompt user for writes, deletes, and shell commands (configurable strictness in config.yaml).
|
||||||
|
|
||||||
|
### Implementation Notes
|
||||||
|
|
||||||
|
<!-- Claude can track TODOs, known issues, technical debt, or areas needing attention -->
|
||||||
|
|
||||||
|
- [ ] Define `BaseTool` ABC and `ToolResult` dataclass before implementing any individual tools
|
||||||
|
- [ ] Implement `registry.py` early — tool schema export is needed to construct the LLM system prompt
|
||||||
|
- [ ] Token counting is approximate (character-based heuristic is fine for v1, tiktoken optional)
|
||||||
|
- [ ] `str_replace` tool should require unique match — fail loudly if the old_str appears more than once (mirrors Claude Code behavior)
|
||||||
|
- [ ] Config loader should validate and error clearly on missing required fields (model name, endpoint)
|
||||||
|
|
||||||
|
### Session History
|
||||||
|
|
||||||
|
<!-- Claude can maintain a log of significant changes or milestones -->
|
||||||
|
|
||||||
|
- Project initialized — CLAUDE.md created, architecture scoped.
|
||||||
|
|
||||||
|
### Active Tasks & Notes
|
||||||
|
|
||||||
|
<!-- Current work, blockers, reminders -->
|
||||||
|
|
||||||
|
- Define project scaffolding (pyproject.toml, folder structure, base files)
|
||||||
|
- Draft `docs/code_guidelines.md` and `docs/security.md`
|
||||||
|
- Implement `BaseTool` + `ToolResult` + `registry.py` first
|
||||||
|
- Implement `filesystem.py` tools with safe path wrappers
|
||||||
|
- Wire up `llm.py` against local Ollama endpoint
|
||||||
|
|
||||||
|
### Change Log
|
||||||
|
|
||||||
|
<!-- Significant updates, refactors, or milestones -->
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-03-11 | Project scoped and CLAUDE.md initialized |
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
0
app/agent/__init__.py
Normal file
72
app/agent/context.py
Normal file
72
app/agent/context.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Session state and conversation history manager."""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.models.config import AppConfig
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.utils.token_counter import TokenCounter
|
||||||
|
|
||||||
|
|
||||||
|
class SessionContext:
|
||||||
|
"""In-memory conversation state manager.
|
||||||
|
|
||||||
|
Tracks conversation history, token usage estimates, and session metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: AppConfig) -> None:
|
||||||
|
"""Initialize session context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Application configuration.
|
||||||
|
"""
|
||||||
|
self._config = config
|
||||||
|
self._history: list[Message] = []
|
||||||
|
self._token_counter = TokenCounter(config.agent.max_conversation_tokens)
|
||||||
|
self._start_time = datetime.now(UTC)
|
||||||
|
self._message_count: int = 0
|
||||||
|
|
||||||
|
def add_message(self, role: str, content: str | None = None, **kwargs: object) -> Message:
|
||||||
|
"""Create and append a message to conversation history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
role: Message role (system, user, assistant, tool).
|
||||||
|
content: Text content of the message.
|
||||||
|
**kwargs: Additional Message fields (tool_calls, tool_call_id, name).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created Message instance.
|
||||||
|
"""
|
||||||
|
message = Message(role=role, content=content, **kwargs) # type: ignore[arg-type]
|
||||||
|
self._history.append(message)
|
||||||
|
self._message_count += 1
|
||||||
|
return message
|
||||||
|
|
||||||
|
def get_history(self) -> list[Message]:
|
||||||
|
"""Return a shallow copy of the conversation history."""
|
||||||
|
return list(self._history)
|
||||||
|
|
||||||
|
def clear_history(self) -> None:
|
||||||
|
"""Clear conversation history and reset counters."""
|
||||||
|
self._history.clear()
|
||||||
|
self._message_count = 0
|
||||||
|
self._token_counter = TokenCounter(self._config.agent.max_conversation_tokens)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def estimated_tokens(self) -> int:
|
||||||
|
"""Estimated token count for the current conversation history."""
|
||||||
|
return self._token_counter.estimate_messages_tokens(self._history)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def token_counter(self) -> TokenCounter:
|
||||||
|
"""The token counter instance."""
|
||||||
|
return self._token_counter
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_count(self) -> int:
|
||||||
|
"""Number of messages added to this session."""
|
||||||
|
return self._message_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_time(self) -> datetime:
|
||||||
|
"""Session start timestamp (UTC)."""
|
||||||
|
return self._start_time
|
||||||
183
app/main.py
Normal file
183
app/main.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""SneakyCode entrypoint — argument parsing, config loading, and interactive REPL."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.agent.context import SessionContext
|
||||||
|
from app.models.config import AppConfig, load_config
|
||||||
|
from app.services.llm import LLMClient, LLMConnectionError, LLMError
|
||||||
|
from app.services.streaming import StreamHandler
|
||||||
|
from app.utils.display import (
|
||||||
|
print_banner,
|
||||||
|
print_error,
|
||||||
|
print_history,
|
||||||
|
print_info,
|
||||||
|
print_success,
|
||||||
|
print_token_usage,
|
||||||
|
print_user_message,
|
||||||
|
print_warning,
|
||||||
|
)
|
||||||
|
from app.utils.logging import console, get_logger, setup_logging
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed arguments namespace.
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="sneakycode",
|
||||||
|
description="SneakyCode — A privacy-first local AI coding agent",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Path to config YAML file (default: config/config.yaml)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v", "--verbose",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Enable verbose (DEBUG) logging",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-file",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Path to log file for persistent logging",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_repl(
|
||||||
|
ctx: SessionContext,
|
||||||
|
config: AppConfig,
|
||||||
|
logger: structlog.stdlib.BoundLogger,
|
||||||
|
) -> None:
|
||||||
|
"""Run the interactive REPL loop with streaming LLM responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: Session context for conversation state.
|
||||||
|
config: Application configuration.
|
||||||
|
logger: Structured logger instance.
|
||||||
|
"""
|
||||||
|
async with LLMClient(config.llm) as client:
|
||||||
|
handler = StreamHandler(config.display)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
user_input = console.input("[bold cyan]> [/bold cyan]")
|
||||||
|
except (KeyboardInterrupt, EOFError):
|
||||||
|
console.print("\n[dim]Goodbye![/dim]")
|
||||||
|
break
|
||||||
|
|
||||||
|
user_input = user_input.strip()
|
||||||
|
if not user_input:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle slash commands
|
||||||
|
if user_input.startswith("/"):
|
||||||
|
command = user_input.lower()
|
||||||
|
if command == "/quit":
|
||||||
|
console.print("[dim]Goodbye![/dim]")
|
||||||
|
break
|
||||||
|
elif command == "/history":
|
||||||
|
print_history(ctx.get_history())
|
||||||
|
elif command == "/clear":
|
||||||
|
ctx.clear_history()
|
||||||
|
print_success("Conversation history cleared.")
|
||||||
|
else:
|
||||||
|
print_warning(f"Unknown command: {user_input}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add user message and display it
|
||||||
|
ctx.add_message("user", user_input)
|
||||||
|
print_user_message(user_input)
|
||||||
|
|
||||||
|
# Stream LLM response
|
||||||
|
try:
|
||||||
|
chunk_iter = client.stream_chat(ctx.get_history())
|
||||||
|
assistant_msg = await handler.process_stream(chunk_iter)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print_warning("Response interrupted.")
|
||||||
|
handler.reset()
|
||||||
|
continue
|
||||||
|
except LLMConnectionError as e:
|
||||||
|
print_error(f"Connection error: {e}")
|
||||||
|
continue
|
||||||
|
except LLMError as e:
|
||||||
|
print_error(f"LLM error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle empty response
|
||||||
|
if assistant_msg.content is None and assistant_msg.tool_calls is None:
|
||||||
|
print_warning("Received empty response from model.")
|
||||||
|
|
||||||
|
# Record assistant message in history
|
||||||
|
ctx.add_message(
|
||||||
|
"assistant",
|
||||||
|
assistant_msg.content,
|
||||||
|
tool_calls=assistant_msg.tool_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Record API token usage if available, fall back to heuristic
|
||||||
|
if handler.usage:
|
||||||
|
ctx.token_counter.count_usage(handler.usage)
|
||||||
|
|
||||||
|
# Show token usage if configured
|
||||||
|
if config.display.show_token_usage:
|
||||||
|
print_token_usage(
|
||||||
|
ctx.token_counter.cumulative_usage.total_tokens
|
||||||
|
or ctx.estimated_tokens,
|
||||||
|
ctx.token_counter.budget,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.reset()
|
||||||
|
logger.debug("message_exchanged", message_count=ctx.message_count)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entrypoint: load config, setup logging, launch interactive REPL."""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# Setup logging first
|
||||||
|
setup_logging(
|
||||||
|
log_file=args.log_file,
|
||||||
|
verbose=args.verbose,
|
||||||
|
)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
try:
|
||||||
|
config = load_config(config_path=args.config)
|
||||||
|
except (FileNotFoundError, ValueError) as e:
|
||||||
|
print_error(f"Configuration error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
logger.info("config_loaded", model=config.llm.model, endpoint=config.llm.endpoint)
|
||||||
|
|
||||||
|
# Print startup info
|
||||||
|
print_banner()
|
||||||
|
print_info(f"Model: {config.llm.model}")
|
||||||
|
print_info(f"Endpoint: {config.llm.endpoint}")
|
||||||
|
print_info(f"Workspace: {config.agent.workspace_root}")
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
print_info("Verbose mode enabled")
|
||||||
|
|
||||||
|
# Create session and start REPL
|
||||||
|
ctx = SessionContext(config)
|
||||||
|
logger.info("startup_complete")
|
||||||
|
|
||||||
|
print_info("Commands: /quit, /history, /clear")
|
||||||
|
asyncio.run(_run_repl(ctx, config, logger))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
15
app/models/__init__.py
Normal file
15
app/models/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""SneakyCode data models."""
|
||||||
|
|
||||||
|
from app.models.config import AppConfig, load_config
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.tool_call import ToolCall, ToolCallFunction, ToolResult, ToolResultStatus
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AppConfig",
|
||||||
|
"load_config",
|
||||||
|
"Message",
|
||||||
|
"ToolCall",
|
||||||
|
"ToolCallFunction",
|
||||||
|
"ToolResult",
|
||||||
|
"ToolResultStatus",
|
||||||
|
]
|
||||||
127
app/models/config.py
Normal file
127
app/models/config.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""Pydantic configuration models mapping to config/config.yaml."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConfig(BaseModel):
|
||||||
|
"""LLM backend configuration."""
|
||||||
|
|
||||||
|
model: str = Field(description="Model name to use")
|
||||||
|
endpoint: str = Field(description="Base URL of the LLM API")
|
||||||
|
api_path: str = Field(default="/v1/chat/completions", description="API endpoint path")
|
||||||
|
temperature: float = Field(default=0.1, description="Sampling temperature")
|
||||||
|
max_tokens: int = Field(default=4096, description="Maximum tokens in LLM response")
|
||||||
|
timeout: int = Field(default=120, description="Request timeout in seconds")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentConfig(BaseModel):
|
||||||
|
"""Agent loop configuration."""
|
||||||
|
|
||||||
|
max_iterations: int = Field(default=25, description="Maximum tool-call loop iterations")
|
||||||
|
max_conversation_tokens: int = Field(
|
||||||
|
default=32000, description="Token budget for conversation history"
|
||||||
|
)
|
||||||
|
workspace_root: Path = Field(
|
||||||
|
default=Path("."), description="Root directory for file operations"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionsConfig(BaseModel):
|
||||||
|
"""Tool permission tiers."""
|
||||||
|
|
||||||
|
auto_approve: list[str] = Field(default_factory=list, description="Auto-approved tools")
|
||||||
|
prompt_user: list[str] = Field(default_factory=list, description="Tools requiring confirmation")
|
||||||
|
deny: list[str] = Field(default_factory=list, description="Tools that are blocked entirely")
|
||||||
|
|
||||||
|
|
||||||
|
class ShellToolConfig(BaseModel):
|
||||||
|
"""Shell tool restrictions."""
|
||||||
|
|
||||||
|
allowed_commands: list[str] = Field(default_factory=list, description="Allowed shell commands")
|
||||||
|
denied_commands: list[str] = Field(default_factory=list, description="Blocked shell commands")
|
||||||
|
max_output_bytes: int = Field(default=65536, description="Max output capture size in bytes")
|
||||||
|
|
||||||
|
|
||||||
|
class FilesystemToolConfig(BaseModel):
|
||||||
|
"""Filesystem tool limits."""
|
||||||
|
|
||||||
|
max_file_size_bytes: int = Field(default=1_048_576, description="Max file size for read/write")
|
||||||
|
binary_detection: bool = Field(default=True, description="Detect and reject binary files")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolsConfig(BaseModel):
|
||||||
|
"""Aggregate tool configuration."""
|
||||||
|
|
||||||
|
shell: ShellToolConfig = Field(default_factory=ShellToolConfig)
|
||||||
|
filesystem: FilesystemToolConfig = Field(default_factory=FilesystemToolConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayConfig(BaseModel):
|
||||||
|
"""Terminal display preferences."""
|
||||||
|
|
||||||
|
show_tool_calls: bool = Field(default=True, description="Show tool call details in output")
|
||||||
|
show_token_usage: bool = Field(default=True, description="Show token usage stats")
|
||||||
|
stream_output: bool = Field(default=True, description="Stream LLM output to terminal")
|
||||||
|
|
||||||
|
|
||||||
|
class AppConfig(BaseModel):
|
||||||
|
"""Top-level application configuration composing all sub-configs."""
|
||||||
|
|
||||||
|
llm: LLMConfig
|
||||||
|
agent: AgentConfig = Field(default_factory=AgentConfig)
|
||||||
|
permissions: PermissionsConfig = Field(default_factory=PermissionsConfig)
|
||||||
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||||
|
display: DisplayConfig = Field(default_factory=DisplayConfig)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def resolve_workspace_root(self) -> "AppConfig":
|
||||||
|
"""Resolve workspace_root to an absolute path."""
|
||||||
|
self.agent.workspace_root = self.agent.workspace_root.resolve()
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# Default config file location relative to project root
|
||||||
|
_DEFAULT_CONFIG_PATH = Path("config/config.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: Path | None = None) -> AppConfig:
|
||||||
|
"""Load and validate application config from YAML.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. Explicit config_path argument
|
||||||
|
2. SNEAKYCODE_CONFIG environment variable
|
||||||
|
3. config/config.yaml (default)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Optional explicit path to config file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated AppConfig instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If the resolved config file does not exist.
|
||||||
|
ValueError: If the config file is invalid YAML or fails validation.
|
||||||
|
"""
|
||||||
|
if config_path is None:
|
||||||
|
env_path = os.environ.get("SNEAKYCODE_CONFIG")
|
||||||
|
if env_path:
|
||||||
|
config_path = Path(env_path)
|
||||||
|
else:
|
||||||
|
config_path = _DEFAULT_CONFIG_PATH
|
||||||
|
|
||||||
|
config_path = config_path.resolve()
|
||||||
|
|
||||||
|
if not config_path.exists():
|
||||||
|
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||||
|
|
||||||
|
with open(config_path) as f:
|
||||||
|
raw = yaml.safe_load(f)
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"Config file must contain a YAML mapping, got {type(raw).__name__}")
|
||||||
|
|
||||||
|
return AppConfig(**raw)
|
||||||
50
app/models/message.py
Normal file
50
app/models/message.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Message schema for LLM conversation history."""
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.tool_call import ToolCall
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
"""A single message in the conversation history.
|
||||||
|
|
||||||
|
Follows the OpenAI chat completions message format with support for
|
||||||
|
system, user, assistant, and tool roles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
role: Literal["system", "user", "assistant", "tool"] = Field(
|
||||||
|
description="Role of the message sender"
|
||||||
|
)
|
||||||
|
content: str | None = Field(default=None, description="Text content of the message")
|
||||||
|
tool_calls: list[ToolCall] | None = Field(
|
||||||
|
default=None, description="Tool calls made by the assistant"
|
||||||
|
)
|
||||||
|
tool_call_id: str | None = Field(
|
||||||
|
default=None, description="ID of the tool call this message responds to (role=tool)"
|
||||||
|
)
|
||||||
|
name: str | None = Field(
|
||||||
|
default=None, description="Name of the tool that produced this message (role=tool)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_api_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialize to a dict suitable for the OpenAI-compatible API.
|
||||||
|
|
||||||
|
Strips None-valued fields to keep the payload clean.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = {"role": self.role}
|
||||||
|
|
||||||
|
if self.content is not None:
|
||||||
|
data["content"] = self.content
|
||||||
|
|
||||||
|
if self.tool_calls is not None:
|
||||||
|
data["tool_calls"] = [tc.model_dump() for tc in self.tool_calls]
|
||||||
|
|
||||||
|
if self.tool_call_id is not None:
|
||||||
|
data["tool_call_id"] = self.tool_call_id
|
||||||
|
|
||||||
|
if self.name is not None:
|
||||||
|
data["name"] = self.name
|
||||||
|
|
||||||
|
return data
|
||||||
37
app/models/tool_call.py
Normal file
37
app/models/tool_call.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Tool call and result models following OpenAI function-calling spec."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCallFunction(BaseModel):
|
||||||
|
"""Function details within a tool call."""
|
||||||
|
|
||||||
|
name: str = Field(description="Name of the tool function to call")
|
||||||
|
arguments: str = Field(description="JSON-encoded string of function arguments")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolCall(BaseModel):
|
||||||
|
"""A single tool call from the LLM, per OpenAI spec."""
|
||||||
|
|
||||||
|
id: str = Field(description="Unique identifier for this tool call")
|
||||||
|
type: str = Field(default="function", description="Type of tool call")
|
||||||
|
function: ToolCallFunction = Field(description="Function to invoke")
|
||||||
|
|
||||||
|
|
||||||
|
class ToolResultStatus(StrEnum):
|
||||||
|
"""Status of a tool execution result."""
|
||||||
|
|
||||||
|
SUCCESS = "success"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class ToolResult(BaseModel):
|
||||||
|
"""Result of executing a tool call."""
|
||||||
|
|
||||||
|
tool_call_id: str = Field(description="ID of the tool call this result corresponds to")
|
||||||
|
tool_name: str = Field(description="Name of the tool that was executed")
|
||||||
|
status: ToolResultStatus = Field(description="Whether the tool execution succeeded or failed")
|
||||||
|
output: str = Field(default="", description="Tool output on success")
|
||||||
|
error: str | None = Field(default=None, description="Error message on failure")
|
||||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
124
app/services/llm.py
Normal file
124
app/services/llm.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""LLM client wrapper for Ollama / OpenAI-compatible endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.models.config import LLMConfig
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Exception hierarchy ---
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
"""Base exception for LLM client errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMConnectionError(LLMError):
|
||||||
|
"""Connection or timeout failure when reaching the LLM endpoint."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMResponseError(LLMError):
|
||||||
|
"""Non-2xx HTTP response from the LLM endpoint."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class LLMStreamError(LLMError):
|
||||||
|
"""Malformed SSE data during streaming."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- Client ---
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
"""Async streaming client for OpenAI-compatible chat completions.
|
||||||
|
|
||||||
|
Designed for Ollama but works with any endpoint implementing the
|
||||||
|
OpenAI /v1/chat/completions SSE streaming protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: LLMConfig) -> None:
|
||||||
|
"""Initialize the LLM client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: LLM configuration (model, endpoint, timeout, etc.).
|
||||||
|
"""
|
||||||
|
self._config = config
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=config.endpoint,
|
||||||
|
timeout=httpx.Timeout(config.timeout, connect=10.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stream_chat(self, messages: list[Message]) -> AsyncIterator[dict]:
|
||||||
|
"""Stream a chat completion request, yielding parsed SSE chunks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: Conversation history to send to the model.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Parsed JSON dicts from each SSE data line.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LLMConnectionError: On connection or timeout failures.
|
||||||
|
LLMResponseError: On non-2xx HTTP status.
|
||||||
|
LLMStreamError: On malformed SSE data (only if every line fails).
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"model": self._config.model,
|
||||||
|
"messages": [m.to_api_dict() for m in messages],
|
||||||
|
"stream": True,
|
||||||
|
"temperature": self._config.temperature,
|
||||||
|
"max_tokens": self._config.max_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._client.stream(
|
||||||
|
"POST", self._config.api_path, json=payload
|
||||||
|
) as response:
|
||||||
|
if response.status_code != 200:
|
||||||
|
body = await response.aread()
|
||||||
|
raise LLMResponseError(
|
||||||
|
f"LLM returned {response.status_code}: {body.decode(errors='replace')}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line.startswith("data: "):
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = line[6:] # strip "data: " prefix
|
||||||
|
|
||||||
|
if data.strip() == "[DONE]":
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield json.loads(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("malformed_sse_chunk", data=data[:200])
|
||||||
|
|
||||||
|
except httpx.ConnectError as e:
|
||||||
|
raise LLMConnectionError(f"Cannot connect to LLM endpoint: {e}") from e
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
raise LLMConnectionError(f"LLM request timed out: {e}") from e
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise LLMError(f"HTTP error communicating with LLM: {e}") from e
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the underlying HTTP client."""
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc: object) -> None:
|
||||||
|
await self.close()
|
||||||
150
app/services/streaming.py
Normal file
150
app/services/streaming.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Streaming response handler — accumulates SSE chunks into a complete Message."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from rich.live import Live
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
|
||||||
|
from app.models.config import DisplayConfig
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.models.tool_call import ToolCall, ToolCallFunction
|
||||||
|
from app.utils.display import print_assistant_message
|
||||||
|
from app.utils.logging import console, get_logger
|
||||||
|
from app.utils.token_counter import TokenUsage
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamHandler:
|
||||||
|
"""Processes an SSE chunk stream into a Rich live display and final Message.
|
||||||
|
|
||||||
|
Accumulates content deltas and tool call fragments, renders a live Markdown
|
||||||
|
panel during streaming, and produces a complete assistant Message on finish.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, display_config: DisplayConfig) -> None:
|
||||||
|
"""Initialize the stream handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
display_config: Display preferences (streaming toggle, etc.).
|
||||||
|
"""
|
||||||
|
self._display_config = display_config
|
||||||
|
self._accumulated_content: str = ""
|
||||||
|
self._accumulated_reasoning: str = ""
|
||||||
|
self._tool_calls: dict[int, dict[str, str]] = {}
|
||||||
|
self._usage: TokenUsage | None = None
|
||||||
|
|
||||||
|
async def process_stream(self, chunk_iter: AsyncIterator[dict]) -> Message:
|
||||||
|
"""Consume a chunk iterator, rendering live output and returning the final Message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chunk_iter: Async iterator of parsed SSE chunk dicts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete assistant Message with accumulated content and tool calls.
|
||||||
|
"""
|
||||||
|
with Live(console=console, refresh_per_second=8) as live:
|
||||||
|
async for chunk in chunk_iter:
|
||||||
|
self._process_chunk(chunk)
|
||||||
|
|
||||||
|
# Show reasoning while waiting for content
|
||||||
|
display_text = self._accumulated_content
|
||||||
|
if not display_text and self._accumulated_reasoning:
|
||||||
|
display_text = f"*thinking...*"
|
||||||
|
|
||||||
|
if display_text and self._display_config.stream_output:
|
||||||
|
live.update(Markdown(display_text))
|
||||||
|
|
||||||
|
# Final static render
|
||||||
|
if self._accumulated_content:
|
||||||
|
print_assistant_message(self._accumulated_content)
|
||||||
|
|
||||||
|
tool_calls = self._build_tool_calls() or None
|
||||||
|
return Message(
|
||||||
|
role="assistant",
|
||||||
|
content=self._accumulated_content or None,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_chunk(self, chunk: dict) -> None:
|
||||||
|
"""Extract content, tool calls, and usage from a single SSE chunk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chunk: Parsed JSON dict from one SSE data line.
|
||||||
|
"""
|
||||||
|
# Content delta
|
||||||
|
choices = chunk.get("choices", [])
|
||||||
|
if choices:
|
||||||
|
delta = choices[0].get("delta", {})
|
||||||
|
|
||||||
|
content_piece = delta.get("content")
|
||||||
|
if content_piece:
|
||||||
|
self._accumulated_content += content_piece
|
||||||
|
|
||||||
|
# Reasoning tokens (e.g. qwen3.5 thinking mode)
|
||||||
|
reasoning_piece = delta.get("reasoning")
|
||||||
|
if reasoning_piece:
|
||||||
|
self._accumulated_reasoning += reasoning_piece
|
||||||
|
|
||||||
|
# Tool call deltas (accumulated by index)
|
||||||
|
for tc_delta in delta.get("tool_calls", []):
|
||||||
|
idx = tc_delta.get("index", 0)
|
||||||
|
if idx not in self._tool_calls:
|
||||||
|
self._tool_calls[idx] = {
|
||||||
|
"id": tc_delta.get("id", ""),
|
||||||
|
"name": "",
|
||||||
|
"arguments": "",
|
||||||
|
}
|
||||||
|
entry = self._tool_calls[idx]
|
||||||
|
if tc_delta.get("id"):
|
||||||
|
entry["id"] = tc_delta["id"]
|
||||||
|
func = tc_delta.get("function", {})
|
||||||
|
if func.get("name"):
|
||||||
|
entry["name"] += func["name"]
|
||||||
|
if func.get("arguments"):
|
||||||
|
entry["arguments"] += func["arguments"]
|
||||||
|
|
||||||
|
# Token usage (typically in the final chunk)
|
||||||
|
usage_data = chunk.get("usage")
|
||||||
|
if usage_data:
|
||||||
|
self._usage = TokenUsage(
|
||||||
|
prompt_tokens=usage_data.get("prompt_tokens", 0),
|
||||||
|
completion_tokens=usage_data.get("completion_tokens", 0),
|
||||||
|
total_tokens=usage_data.get("total_tokens", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_tool_calls(self) -> list[ToolCall]:
|
||||||
|
"""Convert accumulated tool call fragments into sorted ToolCall list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ToolCall objects sorted by stream index.
|
||||||
|
"""
|
||||||
|
if not self._tool_calls:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result: list[ToolCall] = []
|
||||||
|
for idx in sorted(self._tool_calls):
|
||||||
|
entry = self._tool_calls[idx]
|
||||||
|
result.append(
|
||||||
|
ToolCall(
|
||||||
|
id=entry["id"],
|
||||||
|
type="function",
|
||||||
|
function=ToolCallFunction(
|
||||||
|
name=entry["name"],
|
||||||
|
arguments=entry["arguments"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usage(self) -> TokenUsage | None:
|
||||||
|
"""Token usage reported by the API, if available."""
|
||||||
|
return self._usage
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Clear all accumulators for the next turn."""
|
||||||
|
self._accumulated_content = ""
|
||||||
|
self._accumulated_reasoning = ""
|
||||||
|
self._tool_calls.clear()
|
||||||
|
self._usage = None
|
||||||
0
app/tools/__init__.py
Normal file
0
app/tools/__init__.py
Normal file
30
app/utils/__init__.py
Normal file
30
app/utils/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""SneakyCode shared utilities."""
|
||||||
|
|
||||||
|
from app.utils.file_helpers import (
|
||||||
|
BinaryFileError,
|
||||||
|
FileSizeError,
|
||||||
|
PathSecurityError,
|
||||||
|
check_file_size,
|
||||||
|
is_binary_file,
|
||||||
|
resolve_safe_path,
|
||||||
|
safe_read_file,
|
||||||
|
safe_write_file,
|
||||||
|
)
|
||||||
|
from app.utils.logging import console, get_logger, setup_logging
|
||||||
|
from app.utils.token_counter import TokenCounter, TokenUsage
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BinaryFileError",
|
||||||
|
"FileSizeError",
|
||||||
|
"PathSecurityError",
|
||||||
|
"TokenCounter",
|
||||||
|
"TokenUsage",
|
||||||
|
"check_file_size",
|
||||||
|
"console",
|
||||||
|
"get_logger",
|
||||||
|
"is_binary_file",
|
||||||
|
"resolve_safe_path",
|
||||||
|
"safe_read_file",
|
||||||
|
"safe_write_file",
|
||||||
|
"setup_logging",
|
||||||
|
]
|
||||||
103
app/utils/display.py
Normal file
103
app/utils/display.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Rich terminal display helpers for SneakyCode."""
|
||||||
|
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.table import Table
|
||||||
|
from rich.theme import Theme
|
||||||
|
|
||||||
|
from app.models.message import Message
|
||||||
|
from app.utils.logging import console
|
||||||
|
|
||||||
|
# Custom theme for consistent styling across the application
|
||||||
|
SNEAKYCODE_THEME = Theme(
|
||||||
|
{
|
||||||
|
"info": "cyan",
|
||||||
|
"warning": "yellow",
|
||||||
|
"error": "bold red",
|
||||||
|
"success": "bold green",
|
||||||
|
"tool": "magenta",
|
||||||
|
"dim": "dim white",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the theme to the shared console
|
||||||
|
console.push_theme(SNEAKYCODE_THEME)
|
||||||
|
|
||||||
|
|
||||||
|
def print_banner() -> None:
|
||||||
|
"""Print the SneakyCode startup banner."""
|
||||||
|
console.print(
|
||||||
|
"\n[bold cyan] SneakyCode[/bold cyan] [dim]— Local AI Coding Agent[/dim]\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def print_info(message: str) -> None:
|
||||||
|
"""Print an informational message."""
|
||||||
|
console.print(f"[info]{message}[/info]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_warning(message: str) -> None:
|
||||||
|
"""Print a warning message."""
|
||||||
|
console.print(f"[warning]⚠ {message}[/warning]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_error(message: str) -> None:
|
||||||
|
"""Print an error message."""
|
||||||
|
console.print(f"[error]✗ {message}[/error]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_success(message: str) -> None:
|
||||||
|
"""Print a success message."""
|
||||||
|
console.print(f"[success]✓ {message}[/success]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_user_message(content: str) -> None:
|
||||||
|
"""Print a user message in a styled panel."""
|
||||||
|
console.print(Panel(content, title="You", border_style="cyan", expand=False))
|
||||||
|
|
||||||
|
|
||||||
|
def print_assistant_message(content: str) -> None:
|
||||||
|
"""Print an assistant message in a styled panel."""
|
||||||
|
console.print(Panel(content, title="Assistant", border_style="green", expand=False))
|
||||||
|
|
||||||
|
|
||||||
|
def print_tool_call(name: str, args: str) -> None:
|
||||||
|
"""Print a tool call summary (stub for Phase 4)."""
|
||||||
|
console.print(f"[tool]Tool: {name}[/tool] [dim]{args}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_token_usage(usage_tokens: int, budget: int) -> None:
|
||||||
|
"""Print current token usage against budget."""
|
||||||
|
console.print(f"[dim]Tokens: ~{usage_tokens:,} / {budget:,}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def print_history(messages: list[Message]) -> None:
|
||||||
|
"""Print conversation history as a Rich table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of conversation messages to display.
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
console.print("[dim]No messages in history.[/dim]")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = Table(title="Conversation History")
|
||||||
|
table.add_column("#", style="dim", width=4)
|
||||||
|
table.add_column("Role", width=10)
|
||||||
|
table.add_column("Content")
|
||||||
|
|
||||||
|
role_styles = {
|
||||||
|
"user": "cyan",
|
||||||
|
"assistant": "green",
|
||||||
|
"system": "yellow",
|
||||||
|
"tool": "magenta",
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, msg in enumerate(messages, 1):
|
||||||
|
style = role_styles.get(msg.role, "white")
|
||||||
|
content = msg.content or "[dim](no content)[/dim]"
|
||||||
|
# Truncate long content for display
|
||||||
|
if len(content) > 120:
|
||||||
|
content = content[:117] + "..."
|
||||||
|
table.add_row(str(i), f"[{style}]{msg.role}[/{style}]", content)
|
||||||
|
|
||||||
|
console.print(table)
|
||||||
150
app/utils/file_helpers.py
Normal file
150
app/utils/file_helpers.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""Security-critical file operation helpers with path sandboxing."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class PathSecurityError(Exception):
|
||||||
|
"""Raised when a file path escapes the allowed workspace root."""
|
||||||
|
|
||||||
|
|
||||||
|
class FileSizeError(Exception):
|
||||||
|
"""Raised when a file exceeds the configured size limit."""
|
||||||
|
|
||||||
|
|
||||||
|
class BinaryFileError(Exception):
|
||||||
|
"""Raised when an operation is attempted on a binary file."""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_safe_path(path: str | Path, workspace_root: Path) -> Path:
|
||||||
|
"""Resolve a path and verify it is within the workspace root.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: The path to resolve (absolute or relative to workspace_root).
|
||||||
|
workspace_root: The allowed root directory (must be absolute).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resolved absolute path.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PathSecurityError: If the resolved path is outside workspace_root.
|
||||||
|
"""
|
||||||
|
workspace_root = workspace_root.resolve()
|
||||||
|
resolved = (workspace_root / path).resolve()
|
||||||
|
|
||||||
|
if not resolved.is_relative_to(workspace_root):
|
||||||
|
raise PathSecurityError(
|
||||||
|
f"Path '{path}' resolves to '{resolved}' which is outside "
|
||||||
|
f"workspace root '{workspace_root}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def is_binary_file(file_path: Path, sample_size: int = 8192) -> bool:
|
||||||
|
"""Detect if a file is binary by checking for null bytes in a sample.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file to check.
|
||||||
|
sample_size: Number of bytes to read for detection.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the file appears to be binary.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
sample = f.read(sample_size)
|
||||||
|
return b"\x00" in sample
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_file_size(file_path: Path, max_size_bytes: int) -> None:
|
||||||
|
"""Verify that a file does not exceed the size limit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file to check.
|
||||||
|
max_size_bytes: Maximum allowed file size in bytes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileSizeError: If the file exceeds the size limit.
|
||||||
|
FileNotFoundError: If the file does not exist.
|
||||||
|
"""
|
||||||
|
size = file_path.stat().st_size
|
||||||
|
if size > max_size_bytes:
|
||||||
|
raise FileSizeError(
|
||||||
|
f"File '{file_path}' is {size:,} bytes, exceeding the "
|
||||||
|
f"{max_size_bytes:,} byte limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_read_file(
|
||||||
|
path: str | Path,
|
||||||
|
workspace_root: Path,
|
||||||
|
max_size_bytes: int = 1_048_576,
|
||||||
|
check_binary: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Safely read a file with path sandboxing, size, and binary checks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to the file (relative to workspace_root or absolute).
|
||||||
|
workspace_root: The allowed root directory.
|
||||||
|
max_size_bytes: Maximum file size to read.
|
||||||
|
check_binary: Whether to reject binary files.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The file contents as a string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PathSecurityError: If the path escapes the workspace.
|
||||||
|
FileSizeError: If the file is too large.
|
||||||
|
BinaryFileError: If the file is binary and check_binary is True.
|
||||||
|
FileNotFoundError: If the file does not exist.
|
||||||
|
"""
|
||||||
|
safe_path = resolve_safe_path(path, workspace_root)
|
||||||
|
|
||||||
|
if not safe_path.exists():
|
||||||
|
raise FileNotFoundError(f"File not found: {safe_path}")
|
||||||
|
|
||||||
|
check_file_size(safe_path, max_size_bytes)
|
||||||
|
|
||||||
|
if check_binary and is_binary_file(safe_path):
|
||||||
|
raise BinaryFileError(f"File appears to be binary: {safe_path}")
|
||||||
|
|
||||||
|
return safe_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_write_file(
|
||||||
|
path: str | Path,
|
||||||
|
content: str,
|
||||||
|
workspace_root: Path,
|
||||||
|
max_size_bytes: int = 1_048_576,
|
||||||
|
) -> Path:
|
||||||
|
"""Safely write a file with path sandboxing and size checks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path to write to (relative to workspace_root or absolute).
|
||||||
|
content: String content to write.
|
||||||
|
workspace_root: The allowed root directory.
|
||||||
|
max_size_bytes: Maximum allowed content size in bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resolved path that was written to.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PathSecurityError: If the path escapes the workspace.
|
||||||
|
FileSizeError: If the content exceeds the size limit.
|
||||||
|
"""
|
||||||
|
safe_path = resolve_safe_path(path, workspace_root)
|
||||||
|
|
||||||
|
content_size = len(content.encode("utf-8"))
|
||||||
|
if content_size > max_size_bytes:
|
||||||
|
raise FileSizeError(
|
||||||
|
f"Content is {content_size:,} bytes, exceeding the "
|
||||||
|
f"{max_size_bytes:,} byte limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure parent directory exists
|
||||||
|
safe_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
safe_path.write_text(content, encoding="utf-8")
|
||||||
|
|
||||||
|
return safe_path
|
||||||
89
app/utils/logging.py
Normal file
89
app/utils/logging.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Centralized logging setup: structlog for file logs, Rich for terminal output."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
# Shared Rich console instance for the entire application
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(
|
||||||
|
log_file: Path | None = None,
|
||||||
|
log_level: str = "INFO",
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Configure dual logging: Rich handler for terminal, optional file handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_file: Optional path to a log file for structured file logging.
|
||||||
|
log_level: Minimum log level (DEBUG, INFO, WARNING, ERROR).
|
||||||
|
verbose: If True, sets log level to DEBUG regardless of log_level.
|
||||||
|
"""
|
||||||
|
effective_level = "DEBUG" if verbose else log_level.upper()
|
||||||
|
console_level = effective_level if verbose else "WARNING"
|
||||||
|
|
||||||
|
# Configure stdlib root logger
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.setLevel(effective_level)
|
||||||
|
|
||||||
|
# Clear any existing handlers to avoid duplicates on re-init
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
|
||||||
|
# Rich handler for terminal output — only warnings+ unless verbose
|
||||||
|
from rich.logging import RichHandler
|
||||||
|
|
||||||
|
rich_handler = RichHandler(
|
||||||
|
console=console,
|
||||||
|
show_time=True,
|
||||||
|
show_path=verbose,
|
||||||
|
markup=True,
|
||||||
|
rich_tracebacks=True,
|
||||||
|
)
|
||||||
|
rich_handler.setLevel(console_level)
|
||||||
|
root_logger.addHandler(rich_handler)
|
||||||
|
|
||||||
|
# Optional file handler for persistent structured logs
|
||||||
|
if log_file is not None:
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_handler = logging.FileHandler(str(log_file), encoding="utf-8")
|
||||||
|
file_handler.setLevel(effective_level)
|
||||||
|
file_formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
file_handler.setFormatter(file_formatter)
|
||||||
|
root_logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
# Configure structlog to route through stdlib logging
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.filter_by_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.UnicodeDecoder(),
|
||||||
|
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||||
|
"""Get a named structlog logger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Logger name, typically __name__ of the calling module.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A bound structlog logger instance.
|
||||||
|
"""
|
||||||
|
return structlog.get_logger(name)
|
||||||
93
app/utils/token_counter.py
Normal file
93
app/utils/token_counter.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Approximate token counting for conversation budget management."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.message import Message
|
||||||
|
|
||||||
|
|
||||||
|
class TokenUsage(BaseModel):
|
||||||
|
"""Snapshot of token usage for a single LLM call."""
|
||||||
|
|
||||||
|
prompt_tokens: int = Field(default=0, description="Tokens in the prompt")
|
||||||
|
completion_tokens: int = Field(default=0, description="Tokens in the completion")
|
||||||
|
total_tokens: int = Field(default=0, description="Total tokens used")
|
||||||
|
|
||||||
|
|
||||||
|
class TokenCounter:
|
||||||
|
"""Tracks cumulative token usage with character-based estimation.
|
||||||
|
|
||||||
|
Uses a simple heuristic of ~4 characters per token for estimation.
|
||||||
|
This is intentionally approximate — accurate enough for budget tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHARS_PER_TOKEN: int = 4
|
||||||
|
|
||||||
|
def __init__(self, budget: int = 32_000) -> None:
|
||||||
|
"""Initialize the token counter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
budget: Maximum token budget for the conversation.
|
||||||
|
"""
|
||||||
|
self._budget = budget
|
||||||
|
self._cumulative = TokenUsage()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def budget(self) -> int:
|
||||||
|
"""The configured token budget."""
|
||||||
|
return self._budget
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cumulative_usage(self) -> TokenUsage:
|
||||||
|
"""Cumulative token usage across all tracked calls."""
|
||||||
|
return self._cumulative
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_budget(self) -> int:
|
||||||
|
"""Estimated tokens remaining before hitting the budget."""
|
||||||
|
return max(0, self._budget - self._cumulative.total_tokens)
|
||||||
|
|
||||||
|
def estimate_tokens(self, text: str) -> int:
|
||||||
|
"""Estimate token count for a string using character heuristic.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to estimate tokens for.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated token count.
|
||||||
|
"""
|
||||||
|
return max(1, len(text) // self.CHARS_PER_TOKEN)
|
||||||
|
|
||||||
|
def estimate_messages_tokens(self, messages: list[Message]) -> int:
|
||||||
|
"""Estimate total tokens for a list of messages.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
messages: List of conversation messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated total token count.
|
||||||
|
"""
|
||||||
|
total = 0
|
||||||
|
for msg in messages:
|
||||||
|
if msg.content:
|
||||||
|
total += self.estimate_tokens(msg.content)
|
||||||
|
if msg.tool_calls:
|
||||||
|
for tc in msg.tool_calls:
|
||||||
|
total += self.estimate_tokens(tc.function.name)
|
||||||
|
total += self.estimate_tokens(tc.function.arguments)
|
||||||
|
# Per-message overhead (role, formatting)
|
||||||
|
total += 4
|
||||||
|
return total
|
||||||
|
|
||||||
|
def count_usage(self, usage: TokenUsage) -> None:
|
||||||
|
"""Record token usage from an LLM call.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
usage: Token usage from a single call.
|
||||||
|
"""
|
||||||
|
self._cumulative.prompt_tokens += usage.prompt_tokens
|
||||||
|
self._cumulative.completion_tokens += usage.completion_tokens
|
||||||
|
self._cumulative.total_tokens += usage.total_tokens
|
||||||
|
|
||||||
|
def is_over_budget(self) -> bool:
|
||||||
|
"""Check if cumulative usage has exceeded the token budget."""
|
||||||
|
return self._cumulative.total_tokens >= self._budget
|
||||||
61
config/config.yaml
Normal file
61
config/config.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# SneakyCode Configuration
|
||||||
|
|
||||||
|
llm:
|
||||||
|
model: "qwen3.5:latest"
|
||||||
|
endpoint: "http://localhost:11434"
|
||||||
|
api_path: "/v1/chat/completions"
|
||||||
|
temperature: 0.1
|
||||||
|
max_tokens: 4096
|
||||||
|
timeout: 120
|
||||||
|
|
||||||
|
agent:
|
||||||
|
max_iterations: 25
|
||||||
|
max_conversation_tokens: 32000
|
||||||
|
workspace_root: "."
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
auto_approve:
|
||||||
|
- read_file
|
||||||
|
- list_dir
|
||||||
|
- grep_files
|
||||||
|
- find_files
|
||||||
|
prompt_user:
|
||||||
|
- write_file
|
||||||
|
- delete_file
|
||||||
|
- run_command
|
||||||
|
- str_replace
|
||||||
|
- patch_apply
|
||||||
|
- make_dir
|
||||||
|
deny: []
|
||||||
|
|
||||||
|
tools:
|
||||||
|
shell:
|
||||||
|
allowed_commands:
|
||||||
|
- git
|
||||||
|
- python
|
||||||
|
- pip
|
||||||
|
- pytest
|
||||||
|
- ruff
|
||||||
|
- ls
|
||||||
|
- cat
|
||||||
|
- head
|
||||||
|
- tail
|
||||||
|
- wc
|
||||||
|
- diff
|
||||||
|
- grep
|
||||||
|
- find
|
||||||
|
- echo
|
||||||
|
denied_commands:
|
||||||
|
- rm -rf /
|
||||||
|
- sudo
|
||||||
|
- curl
|
||||||
|
- wget
|
||||||
|
max_output_bytes: 65536
|
||||||
|
filesystem:
|
||||||
|
max_file_size_bytes: 1048576 # 1 MB
|
||||||
|
binary_detection: true
|
||||||
|
|
||||||
|
display:
|
||||||
|
show_tool_calls: true
|
||||||
|
show_token_usage: true
|
||||||
|
stream_output: true
|
||||||
20
docs/CLAUDE.md
Normal file
20
docs/CLAUDE.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Documentation Folder
|
||||||
|
|
||||||
|
This folder contains business planning, architecture decisions, and documentation.
|
||||||
|
|
||||||
|
**No application code belongs here.**
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| Document | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `code_guidelinees.md` | Code creation guidelines and Git strategy |
|
||||||
|
| `security.md` | Python focused security code suggestions |
|
||||||
|
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Keep documents focused and concise
|
||||||
|
- Update docs when architecture decisions change
|
||||||
|
- Use markdown tables for structured information
|
||||||
|
- Include links to external documentation where relevant
|
||||||
144
docs/ROADMAP.md
Normal file
144
docs/ROADMAP.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# SneakyCode Implementation Roadmap
|
||||||
|
|
||||||
|
A phased plan progressing from bare-bones foundation to full autonomous coding agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Foundation: Models, Config, and Utilities
|
||||||
|
|
||||||
|
Establish the data layer and shared infrastructure everything else builds on.
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `app/models/config.py` | Pydantic v2 config model — load and validate `config/config.yaml` |
|
||||||
|
| `app/models/message.py` | Message schema (role, content, tool_calls) |
|
||||||
|
| `app/models/tool_call.py` | ToolCall and ToolResult schemas |
|
||||||
|
| `app/utils/logging.py` | Centralized logger with Rich handler |
|
||||||
|
| `app/utils/display.py` | Rich console output helpers (stub — expanded in Phase 2) |
|
||||||
|
| `app/utils/file_helpers.py` | Safe path resolution, binary detection, size guards |
|
||||||
|
| `app/utils/token_counter.py` | Approximate token usage tracking (character-based heuristic for v1) |
|
||||||
|
| `app/main.py` | Entrypoint stub — arg parsing, config load, Rich console setup |
|
||||||
|
|
||||||
|
**Exit criteria:** `python -m app.main --help` runs, config loads and validates, models can be instantiated and serialized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — TUI and Interactive Shell
|
||||||
|
|
||||||
|
Get a working interactive terminal before wiring up the LLM.
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `app/main.py` | Rich-based interactive REPL loop — prompt for user input, display responses |
|
||||||
|
| `app/utils/display.py` | Formatted output for agent messages, tool calls, errors, token usage |
|
||||||
|
| `app/agent/context.py` | Session state and conversation history management |
|
||||||
|
|
||||||
|
**Exit criteria:** User can type messages into a styled REPL, see them echoed back with formatting, and conversation history is tracked in memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — LLM Integration (Ollama)
|
||||||
|
|
||||||
|
Connect to the local LLM and stream responses into the TUI.
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `app/services/llm.py` | Async httpx client wrapping Ollama's OpenAI-compatible `/v1/chat/completions` endpoint |
|
||||||
|
| `app/services/streaming.py` | SSE parsing, Rich live display, tool call extraction from accumulated stream |
|
||||||
|
|
||||||
|
**Integration:** Wire LLM into the REPL — user message goes to LLM, streamed response displays in real time.
|
||||||
|
|
||||||
|
**Exit criteria:** User can chat with the local model through the TUI with streamed output. Tool call JSON is parsed from the stream but not yet executed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Tool Framework and Core Tools
|
||||||
|
|
||||||
|
Build the tool abstraction and implement safe, read-only tools first.
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `app/tools/base.py` | `BaseTool` ABC and `ToolResult` dataclass |
|
||||||
|
| `app/tools/registry.py` | Tool registration, discovery, and JSON schema export for LLM system prompt |
|
||||||
|
| `app/services/permissions.py` | Two-tier approval gating (auto-approve reads; prompt for writes/deletes/shell) |
|
||||||
|
| `app/tools/filesystem.py` | `read_file`, `list_dir` |
|
||||||
|
| `app/tools/search.py` | `grep_files`, `find_files` |
|
||||||
|
|
||||||
|
**Exit criteria:** Tools register themselves, schemas export correctly for inclusion in the system prompt, read-only tools execute and return `ToolResult` objects. Permissions service gates execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Agent Loop (ReAct)
|
||||||
|
|
||||||
|
The core autonomy layer — reason, act, observe, repeat.
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `app/agent/loop.py` | ReAct cycle: send conversation to LLM, parse tool calls, execute, feed results back, repeat |
|
||||||
|
|
||||||
|
**Key behaviors:**
|
||||||
|
- System prompt constructed with tool schemas from registry
|
||||||
|
- Permissions checks before each tool execution
|
||||||
|
- Loop termination on: plain-text response (no tool calls), explicit `finish` tool call, or `max_iterations` exceeded
|
||||||
|
|
||||||
|
**Exit criteria:** Agent can autonomously answer questions about the codebase by chaining `read_file`, `list_dir`, `grep_files`, and `find_files` tool calls in a multi-turn loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Write Tools and Shell
|
||||||
|
|
||||||
|
Unlock the agent's ability to modify code and run commands.
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `app/tools/filesystem.py` | `write_file`, `make_dir`, `delete_file` (additions to existing module) |
|
||||||
|
| `app/tools/edit.py` | `str_replace` (unique-match required), `patch_apply` |
|
||||||
|
| `app/tools/shell.py` | `run_command` with command allow/deny lists and output truncation |
|
||||||
|
|
||||||
|
**All write/shell operations gated through permissions service.**
|
||||||
|
|
||||||
|
**Exit criteria:** Agent can autonomously create files, edit code via string replacement, and run shell commands — all with user approval for destructive operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Polish and Hardening
|
||||||
|
|
||||||
|
Production-readiness: error handling, resource limits, and documentation.
|
||||||
|
|
||||||
|
| Area | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Error handling | Recovery from malformed tool calls, LLM errors, network timeouts in agent loop |
|
||||||
|
| Token budget | Conversation truncation or summarization when approaching context limit |
|
||||||
|
| Graceful shutdown | Clean Ctrl+C handling, session state preservation |
|
||||||
|
| Testing | End-to-end integration tests (`tests/integration/`), unit tests (`tests/unit/`) |
|
||||||
|
| Documentation | `README.md` with setup and usage instructions, `docs/tools.md` tool reference |
|
||||||
|
|
||||||
|
**Exit criteria:** Agent handles edge cases gracefully, tests pass, and a new user can set up and use the project from the README alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Coverage
|
||||||
|
|
||||||
|
Every file from the project structure in CLAUDE.md is accounted for:
|
||||||
|
|
||||||
|
| File | Phase |
|
||||||
|
|------|-------|
|
||||||
|
| `app/main.py` | 1, 2 |
|
||||||
|
| `app/models/config.py` | 1 |
|
||||||
|
| `app/models/message.py` | 1 |
|
||||||
|
| `app/models/tool_call.py` | 1 |
|
||||||
|
| `app/utils/logging.py` | 1 |
|
||||||
|
| `app/utils/display.py` | 1, 2 |
|
||||||
|
| `app/utils/file_helpers.py` | 1 |
|
||||||
|
| `app/utils/token_counter.py` | 1 |
|
||||||
|
| `app/agent/context.py` | 2 |
|
||||||
|
| `app/services/llm.py` | 3 |
|
||||||
|
| `app/services/streaming.py` | 3 |
|
||||||
|
| `app/tools/base.py` | 4 |
|
||||||
|
| `app/tools/registry.py` | 4 |
|
||||||
|
| `app/services/permissions.py` | 4 |
|
||||||
|
| `app/tools/filesystem.py` | 4, 6 |
|
||||||
|
| `app/tools/search.py` | 4 |
|
||||||
|
| `app/agent/loop.py` | 5 |
|
||||||
|
| `app/tools/edit.py` | 6 |
|
||||||
|
| `app/tools/shell.py` | 6 |
|
||||||
120
docs/code_guidelines.md
Normal file
120
docs/code_guidelines.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
### Coding Standards
|
||||||
|
|
||||||
|
**Style & Structure**
|
||||||
|
- Prefer longer, explicit code over compact one-liners
|
||||||
|
- Always include docstrings for functions/classes + inline comments
|
||||||
|
- Strongly prefer OOP-style code (classes over functional/nested functions)
|
||||||
|
- Strong typing throughout (dataclasses, TypedDict, Enums, type hints)
|
||||||
|
- Value future-proofing and expanded usage insights
|
||||||
|
|
||||||
|
**Data Design**
|
||||||
|
- Use dataclasses for internal data modeling
|
||||||
|
- Typed JSON structures
|
||||||
|
- Functions return fully typed objects (no loose dicts)
|
||||||
|
- Snapshot files in JSON or YAML
|
||||||
|
- Human-readable fields (e.g., `scan_duration`)
|
||||||
|
|
||||||
|
**Templates & UI**
|
||||||
|
- Don't mix large HTML/CSS blocks in Python code
|
||||||
|
- Prefer Jinja templates for HTML rendering
|
||||||
|
- Clean CSS, minimal inline clutter, readable template logic
|
||||||
|
|
||||||
|
**Writing & Documentation**
|
||||||
|
- Markdown documentation
|
||||||
|
- Clear section headers
|
||||||
|
- Roadmap/Phase/Feature-Session style documents
|
||||||
|
- Boilerplate templates first, then refinements
|
||||||
|
|
||||||
|
**Logging**
|
||||||
|
- Use structlog (pip package)
|
||||||
|
- Setup logging at app start: `logger = logging.get_logger(__file__)`
|
||||||
|
|
||||||
|
**Preferred Pip Packages**
|
||||||
|
- API/Web Server: Flask
|
||||||
|
- HTTP: Requests
|
||||||
|
- Logging: Structlog
|
||||||
|
- Scheduling: APScheduler
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Custom exception classes for domain-specific errors
|
||||||
|
- Consistent error response formats (JSON structure)
|
||||||
|
- Logging severity levels (ERROR vs WARNING)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- Each component has environment-specific configs in its own `/config/*.yaml`
|
||||||
|
- API: `/api/config/development.yaml`, `/api/config/production.yaml`
|
||||||
|
- Web: `/public_web/config/development.yaml`, `/public_web/config/production.yaml`
|
||||||
|
- `.env` for secrets (never committed)
|
||||||
|
- Maintain `.env.example` in each component for documentation
|
||||||
|
- Typed config loaders using dataclasses
|
||||||
|
- Validation on startup
|
||||||
|
|
||||||
|
### Containerization & Deployment
|
||||||
|
- Explicit Dockerfiles
|
||||||
|
- Production-friendly hardening (distroless/slim when meaningful)
|
||||||
|
- Clear build/push scripts that:
|
||||||
|
- Use git branch as tag
|
||||||
|
- Ask whether to tag `:latest`
|
||||||
|
- Ask whether to push
|
||||||
|
- Support private registries
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
- RESTful conventions
|
||||||
|
- Versioning strategy (`/api/v1/...`)
|
||||||
|
- Standardized response format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app": "<APP NAME>",
|
||||||
|
"version": "<APP VERSION>",
|
||||||
|
"status": <HTTP STATUS CODE>,
|
||||||
|
"timestamp": "<UTC ISO8601>",
|
||||||
|
"request_id": "<optional request id>",
|
||||||
|
"result": <data OR null>,
|
||||||
|
"error": {
|
||||||
|
"code": "<optional machine code>",
|
||||||
|
"message": "<human message>",
|
||||||
|
"details": {}
|
||||||
|
},
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
- Use `requirements.txt` and virtual environments (`python3 -m venv venv`)
|
||||||
|
- Use path `venv` for all virtual environments
|
||||||
|
- Pin versions to version ranges
|
||||||
|
- Activate venv before running code (unless in Docker)
|
||||||
|
|
||||||
|
### Testing Standards
|
||||||
|
- Manual testing preferred for applications
|
||||||
|
- **API Backend:** Maintain `api/docs/API_TESTING.md` with endpoint examples, curl/httpie commands, expected responses
|
||||||
|
- **Unit tests:** Use pytest for API backend (`api/tests/`)
|
||||||
|
- **Web Frontend:** If using a web frontend, Manual testing checklist are created in `public_web/docs`
|
||||||
|
|
||||||
|
### Git Standards
|
||||||
|
|
||||||
|
**Branch Strategy:**
|
||||||
|
- `master` - Production-ready code only
|
||||||
|
- `dev` - Main development branch, integration point
|
||||||
|
- `beta` - (Optional) Public pre-release testing
|
||||||
|
|
||||||
|
**Workflow:**
|
||||||
|
- Feature work branches off `dev` (e.g., `feature/add-scheduler`)
|
||||||
|
- Merge features back to `dev` for testing
|
||||||
|
- Promote `dev` → `beta` for public testing (when applicable)
|
||||||
|
- Promote `beta` (or `dev`) → `master` for production
|
||||||
|
|
||||||
|
**Commit Messages:**
|
||||||
|
- Use conventional commit format: `feat:`, `fix:`, `docs:`, `refactor:`, etc.
|
||||||
|
- Keep commits atomic and focused
|
||||||
|
- Write clear, descriptive messages
|
||||||
|
|
||||||
|
**Tagging:**
|
||||||
|
- Tag releases on `master` with semantic versioning (e.g., `v1.2.3`)
|
||||||
|
- Optionally tag beta releases (e.g., `v1.2.3-beta.1`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflow Preference
|
||||||
|
I follow a pattern: **brainstorm → design → code → revise**
|
||||||
46
docs/security.md
Normal file
46
docs/security.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
## Foundational Security Instructions
|
||||||
|
|
||||||
|
- Act as a security-aware software engineer generating secure Python code.
|
||||||
|
- Produce implementations that are **secure-by-design and secure-by-default**, not merely cosmetically "secured."
|
||||||
|
- Focus on **preventing vulnerabilities**, not renaming functions or adding superficial security wrappers.
|
||||||
|
- Explicitly identify **trust boundaries** (user input, external systems, internal components) and apply stricter controls at all boundary crossings.
|
||||||
|
- Treat **all external input as untrusted by default**, regardless of source, and validate or sanitize it before use.
|
||||||
|
- Explicitly consider **data sensitivity** (e.g., public, internal, confidential, regulated) and enforce controls appropriate to the highest sensitivity level involved.
|
||||||
|
- Clearly distinguish between **authentication**, **authorization**, and **session management**, and never conflate their responsibilities.
|
||||||
|
- Ensure implementations **fail securely**: errors, exceptions, and edge cases MUST NOT expose sensitive data or weaken security guarantees.
|
||||||
|
- Use inline comments (when generating code) to clearly highlight critical security controls, assumptions, and security-relevant design decisions.
|
||||||
|
- Adhere strictly to OWASP best practices, with particular consideration for the OWASP ASVS.
|
||||||
|
- **Avoid slopsquatting and dependency confusion**: never guess package names or APIs; only reference well-known, reputable, and maintained libraries. Explicitly note any uncommon or low-reputation dependencies.
|
||||||
|
- Do not hardcode secrets, credentials, tokens, or cryptographic material. Always require secure external configuration or secret management mechanisms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Weaknesses for Python
|
||||||
|
|
||||||
|
### CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
|
||||||
|
**Summary:** Failure to properly sanitize or encode user input can lead to injection of malicious scripts into web pages, enabling XSS attacks.
|
||||||
|
**Mitigation Rule:** All user input rendered in web pages MUST be sanitized and contextually encoded using a secure library such as `bleach` or `html.escape`.
|
||||||
|
|
||||||
|
### CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
|
||||||
|
**Summary:** Unsanitized user input in SQL queries can allow attackers to execute arbitrary SQL commands, compromising data integrity and confidentiality.
|
||||||
|
**Mitigation Rule:** SQL queries MUST use parameterized statements or prepared statements provided by libraries such as `sqlite3` or `SQLAlchemy`. Direct concatenation of user input into queries MUST NOT be used.
|
||||||
|
|
||||||
|
### CWE-327: Use of a Broken or Risky Cryptographic Algorithm
|
||||||
|
**Summary:** Using outdated or insecure cryptographic algorithms can compromise data confidentiality and integrity.
|
||||||
|
**Mitigation Rule:** Cryptographic operations MUST use secure algorithms provided by the `cryptography` library. Deprecated algorithms such as MD5 or SHA-1 MUST NOT be used.
|
||||||
|
|
||||||
|
### CWE-798: Use of Hard-coded Credentials
|
||||||
|
**Summary:** Hardcoding credentials in source code can lead to unauthorized access if the code is exposed or leaked.
|
||||||
|
**Mitigation Rule:** Secrets, credentials, and tokens MUST be stored securely using environment variables, secret management tools, or configuration files outside the source code repository.
|
||||||
|
|
||||||
|
### CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
|
||||||
|
**Summary:** Improper error handling or logging can expose sensitive data to unauthorized users.
|
||||||
|
**Mitigation Rule:** Error messages and logs MUST NOT include sensitive information such as stack traces, database connection strings, or user credentials. Use logging libraries such as `logging` with appropriate log levels and sanitization.
|
||||||
|
|
||||||
|
### CWE-502: Deserialization of Untrusted Data
|
||||||
|
**Summary:** Deserializing untrusted data can lead to arbitrary code execution or data tampering.
|
||||||
|
**Mitigation Rule:** Deserialization MUST only be performed on trusted data sources. Unsafe libraries such as `pickle` MUST NOT be used for deserialization of untrusted input.
|
||||||
|
|
||||||
|
### CWE-829: Inclusion of Functionality from Untrusted Control Sphere
|
||||||
|
**Summary:** Using dependencies or code from untrusted sources can introduce malicious functionality or vulnerabilities.
|
||||||
|
**Mitigation Rule:** Dependencies MUST be sourced from reputable package repositories such as PyPI. Verify the integrity and reputation of packages before use, and pin dependency versions to avoid supply chain attacks.
|
||||||
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69.0", "setuptools-scm"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "sneakycode"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A privacy-first, locally-running Python coding agent using local LLMs"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"pydantic>=2.5,<3",
|
||||||
|
"rich>=13.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"structlog>=24.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"ruff>=0.3",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
sneakycode = "app.main:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
442
uv.lock
generated
Normal file
442
uv.lock
generated
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "annotated-types"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.2.25"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "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 = "pydantic"
|
||||||
|
version = "2.12.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "annotated-types" },
|
||||||
|
{ name = "pydantic-core" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "typing-inspection" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pydantic-core"
|
||||||
|
version = "2.41.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "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.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sneakycode"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "structlog" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.5,<3" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
|
{ name = "rich", specifier = ">=13.0" },
|
||||||
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3" },
|
||||||
|
{ name = "structlog", specifier = ">=24.0" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "structlog"
|
||||||
|
version = "25.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-inspection"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user