diff --git a/.gitignore b/.gitignore index 9f8719d..1e01cd7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,7 @@ __pycache__/ dist/ build/ -# Database -data/*.db +# Database journals (temp files) data/*.db-journal # IDE diff --git a/.planning/MILESTONES.md b/.planning/MILESTONES.md new file mode 100644 index 0000000..474fc47 --- /dev/null +++ b/.planning/MILESTONES.md @@ -0,0 +1,25 @@ +# Milestones + +## v1.0 — Core Requirements Management (Complete) + +**Released:** 2026-03-18 +**Tag:** v1.0.0 +**Commits:** 33 + +### What Shipped +- Interactive DAG graph with semantic zoom (4 levels) +- TMCS quality gates with enforcement +- Hierarchical decomposition (5 node types) +- Cross-pillar satisfaction links +- Edit history with version restore +- Named graph layouts +- Canvas undo (Ctrl+Z) +- Dashboard statistics +- Password auth with JWT +- Focus mode (shallow + deep) +- Gitea badge on graph nodes +- Full API with Swagger docs + +### Phases +- No formal phase tracking (pre-GSD) +- Last phase number: 0 diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..621a2f9 --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,84 @@ +# req-planner + +## What This Is + +A standalone requirements management tool with interactive graph visualization, hierarchical decomposition, TMCS quality gates, and code traceability. Built as a general-purpose planning tool that connects to any repository. Used by a single developer to decompose project goals into traceable, testable requirements organized as a DAG. + +## Core Value + +Every requirement is traceable from abstract goal to code-level leaf with quality validation at every level — the requirements graph IS the code graph. + +## Current Milestone: v2.0 Multi-Tenancy & User Accounts + +**Goal:** Replace single shared password with individual user accounts, per-user workspaces, role-based access control, and project sharing. + +**Target features:** +- User registration and login (individual accounts) +- Per-user project ownership and workspaces +- Role-based access control (owner, editor, viewer) +- Project sharing and collaboration + +## Requirements + +### Validated + + + +- ✓ Hierarchical requirement decomposition (pillars → requirements → sub-requirements → leaves) — v1.0 +- ✓ Interactive DAG graph with Cytoscape.js + dagre layout — v1.0 +- ✓ Semantic zoom (4 detail levels) — v1.0 +- ✓ TMCS quality gates with auto/manual validation — v1.0 +- ✓ Measurable gate enforcement (forced false without test spec) — v1.0 +- ✓ Cross-pillar satisfaction links — v1.0 +- ✓ Password-based authentication with JWT — v1.0 +- ✓ Full CRUD for requirements, projects, links, layouts — v1.0 +- ✓ Edit history with version snapshots and restore — v1.0 +- ✓ Named graph layouts (save/load/default) — v1.0 +- ✓ Canvas undo (Ctrl+Z, 20-state stack) — v1.0 +- ✓ Dashboard with statistics — v1.0 +- ✓ Gitea issue integration fields — v1.0 +- ✓ Focus mode (shallow + deep) with state preservation — v1.0 +- ✓ Multi-shortcut graph interactions (Click, Shift, Alt, Ctrl, Right-click) — v1.0 +- ✓ Gitea badge on graph nodes — v1.0 +- ✓ Detail panel with inline editing, history, links — v1.0 + +### Active + + + +(Defining requirements) + +### Out of Scope + +- Real-time collaborative editing — complexity too high for v2, consider v3 +- OAuth/SSO providers — email/password sufficient initially +- Mobile app — web-first + +## Context + +- Backend: Python 3.12+, FastAPI, SQLAlchemy 2.0, SQLite +- Frontend: Angular 17, Cytoscape.js, RxJS, SCSS dark theme +- Database: SQLite at data/planner.db with 5 tables (requirement_nodes, cross_pillar_links, projects, requirement_history, graph_layouts) +- Current auth: single shared password with JWT tokens (24h expiry) +- Gitea instance at theres-a-git-in-this-tea.andrewawesomo.net for issue tracking +- Currently single-user — all data shared, no user isolation + +## Constraints + +- **Tech stack**: Must stay FastAPI + Angular + SQLite — no database migration +- **Backwards compatible**: Existing data must survive the migration +- **Self-hosted**: Runs on user's own infrastructure, no cloud dependencies + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| SQLite over PostgreSQL | Single-user simplicity, zero config | ✓ Good | +| JWT over sessions | Stateless, works with SPA | ✓ Good | +| Cytoscape.js for graph | Mature, supports semantic zoom | ✓ Good | +| localStorage for transient state | Fast, no backend round-trips | ✓ Good | +| Backend DB for durable state | Survives cache clear, device-independent | ✓ Good | +| Single password auth | Quick to implement for v1 | ⚠️ Revisit — replacing in v2 | + +--- +*Last updated: 2026-03-18 after v1.0 release* diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..d8e7d35 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,23 @@ +# State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-03-18) + +**Core value:** Every requirement is traceable from abstract goal to code-level leaf with quality validation at every level +**Current focus:** Defining requirements for v2.0 + +## Current Position + +Phase: Not started (defining requirements) +Plan: — +Status: Defining requirements +Last activity: 2026-03-18 — Milestone v2.0 started + +## Accumulated Context + +- v1.0 released and tagged (33 commits) +- Backend runs on port 8100, frontend on 4200 +- Current auth is single shared password ("what_is_this_2") with JWT +- SQLite DB has 5 tables, auto-created via create_all() +- Gitea milestone #15 created for v2.0 diff --git a/.planning/config.json b/.planning/config.json new file mode 100644 index 0000000..737bdb7 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,17 @@ +{ + "version": "1.0.0", + "workflow": { + "research": true, + "commit_docs": true, + "auto_verify": false + }, + "models": { + "researcher": "sonnet", + "synthesizer": "sonnet", + "planner": "sonnet", + "plan_checker": "sonnet", + "executor": "sonnet", + "verifier": "sonnet", + "roadmapper": "sonnet" + } +} diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md new file mode 100644 index 0000000..473de0a --- /dev/null +++ b/.planning/research/ARCHITECTURE.md @@ -0,0 +1,1305 @@ +# Architecture Research: Multi-Tenancy & User Accounts Integration + +**Domain:** Requirements management system (FastAPI + SQLAlchemy 2.0 + SQLite) +**Researched:** 2026-03-18 +**Confidence:** HIGH + +## Executive Summary + +Adding user accounts and multi-tenancy to an existing FastAPI + SQLAlchemy + SQLite application requires four architectural layers: **Authentication** (user identity), **Authorization** (permissions), **Tenant Isolation** (data separation), and **Migration Strategy** (backwards compatibility). The recommended approach uses shared-schema multi-tenancy with tenant-scoped queries, JWT tokens with refresh tokens, RBAC via junction tables, and FastAPI's dependency injection for context management. + +**Key decision:** Use shared-schema multi-tenancy (single database, user_id + tenant_id filtering) rather than database-per-tenant, because SQLite's single-writer lock makes multiple active databases perform worse than filtered queries on a single database. + +## Existing Architecture Analysis + +### Current State + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Angular 17 Frontend │ +│ - AuthGuard + HTTP Interceptor for token injection │ +│ - localStorage for token storage │ +└─────────────────────┬───────────────────────────────────────┘ + │ HTTP/JSON +┌─────────────────────┴───────────────────────────────────────┐ +│ FastAPI Backend │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ auth.py: Single password → JWT (24h, no refresh) │ │ +│ │ verify_token() dependency on all protected routes │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Routers: requirements, projects, links, layouts, │ │ +│ │ stats (all protected by verify_token) │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ SQLAlchemy 2.0 ORM with get_db() dependency │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────────┴───────────────────────────────────────┐ +│ SQLite Database │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ requirement_nodes│ │ projects │ │ +│ │ (no user_id) │ │ (no user_id) │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ cross_pillar_links│ │ requirement_history│ │ +│ │ │ │ (no user_id) │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ │ +│ │ graph_layouts │ │ +│ │ (no user_id) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Architecture Gaps + +| Component | Current State | Required for v2.0 | +|-----------|---------------|-------------------| +| **Authentication** | Single password, 24h JWT | User accounts with email/password, refresh tokens | +| **User Model** | None | Users table with hashed passwords | +| **Tenant Isolation** | None (all data shared) | Per-user project ownership, user_id filtering | +| **Authorization** | Binary (authenticated or not) | Role-based access (owner/editor/viewer per project) | +| **Data Attribution** | No edit tracking | User attribution in history | +| **Invite System** | N/A | Invite-only registration with tokens | +| **Session Management** | Stateless JWT (24h only) | Refresh tokens (long-lived, revocable) | + +## Recommended Architecture + +### Target State with Multi-Tenancy + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Angular 17 Frontend │ +│ - AuthGuard with user profile │ +│ - HTTP Interceptor with token refresh logic │ +│ - localStorage: access_token + refresh_token │ +└─────────────────────┬───────────────────────────────────────┘ + │ HTTP/JSON +┌─────────────────────┴───────────────────────────────────────┐ +│ FastAPI Backend │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Authentication Layer (auth.py + new users.py) │ │ +│ │ - POST /auth/register (invite token required) │ │ +│ │ - POST /auth/login (email + password → tokens) │ │ +│ │ - POST /auth/refresh (refresh_token → new access) │ │ +│ │ - POST /auth/logout (revoke refresh_token) │ │ +│ │ - GET /auth/me (current user profile) │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Dependency Injection Context │ │ +│ │ get_current_user() → UserContext (user_id, email) │ │ +│ │ require_project_access(role) → enforces RBAC │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Tenant-Scoped Query Middleware │ │ +│ │ Auto-inject user_id filter on all queries │ │ +│ │ (via SQLAlchemy session events) │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Routers (modified for user context) │ │ +│ │ - requirements: add created_by, updated_by │ │ +│ │ - projects: add owner_id, check permissions │ │ +│ │ - links, layouts, stats: scope to accessible projects │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ SQLAlchemy 2.0 ORM │ │ +│ │ get_db_with_tenant_filter() → scoped session │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────┬───────────────────────────────────────┘ + │ +┌─────────────────────┴───────────────────────────────────────┐ +│ SQLite Database │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ users │ │ projects │ │ +│ │ (NEW) │ │ + owner_id (FK) │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ project_members │ │ requirement_nodes│ │ +│ │ (NEW - RBAC) │ │ + created_by_id │ │ +│ │ │ │ + updated_by_id │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ invite_tokens │ │ requirement_history│ │ +│ │ (NEW) │ │ + user_id │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ refresh_tokens │ │ graph_layouts │ │ +│ │ (NEW) │ │ (no change) │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ ┌──────────────────┐ │ +│ │ cross_pillar_links│ (no change - inherits access from │ +│ │ │ parent requirement) │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## New Database Schema + +### New Tables + +#### 1. users (Core Identity) + +```python +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # Global admin flag + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # Relationships + owned_projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner") + project_memberships: Mapped[list["ProjectMember"]] = relationship( + "ProjectMember", back_populates="user", cascade="all, delete-orphan" + ) + created_requirements: Mapped[list["RequirementNode"]] = relationship( + "RequirementNode", foreign_keys="RequirementNode.created_by_id", back_populates="created_by" + ) + refresh_tokens: Mapped[list["RefreshToken"]] = relationship( + "RefreshToken", back_populates="user", cascade="all, delete-orphan" + ) + +__table_args__ = ( + Index("idx_users_email_active", "email", "is_active"), # Fast login lookup +) +``` + +**Why this design:** +- `email` as primary identity (unique, indexed for fast login) +- `is_admin` for global operations (e.g., invite token generation) +- `is_active` for soft account suspension without data loss +- `last_login` for monitoring and security auditing + +#### 2. project_members (RBAC Junction Table) + +```python +class ProjectMemberRole(str, enum.Enum): + owner = "owner" # Full control, can delete project + editor = "editor" # Can modify requirements, cannot manage members + viewer = "viewer" # Read-only access + +class ProjectMember(Base): + __tablename__ = "project_members" + + project_id: Mapped[int] = mapped_column( + Integer, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True + ) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + role: Mapped[ProjectMemberRole] = mapped_column( + Enum(ProjectMemberRole), nullable=False, default=ProjectMemberRole.viewer + ) + added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + added_by_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + # Relationships + project: Mapped["Project"] = relationship("Project", back_populates="members") + user: Mapped["User"] = relationship("User", back_populates="project_memberships") + +__table_args__ = ( + Index("idx_project_members_user", "user_id"), # Find all projects for a user + Index("idx_project_members_project", "project_id"), # Find all members of a project +) +``` + +**Why this design:** +- Composite primary key (project_id, user_id) prevents duplicate memberships +- Three-tier role model (owner/editor/viewer) covers most collaboration patterns +- `added_by_id` for audit trail (who invited whom) +- Cascade delete ensures cleanup when projects or users are deleted + +#### 3. invite_tokens (Invite-Only Registration) + +```python +class InviteToken(Base): + __tablename__ = "invite_tokens" + + token: Mapped[str] = mapped_column(String(64), primary_key=True) # UUID or secure random + email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + created_by_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + used_by_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + +__table_args__ = ( + Index("idx_invite_tokens_email_used", "email", "used_at"), # Check if email already invited + Index("idx_invite_tokens_expires", "expires_at"), # Cleanup expired tokens +) +``` + +**Why this design:** +- Token as primary key (opaque, unpredictable) +- `email` scoped invites prevent multiple registrations with same token +- `expires_at` for time-limited invites (e.g., 7 days) +- `used_at` / `used_by_id` track invite consumption (one-time use) +- Created by existing users (only authenticated users can invite) + +#### 4. refresh_tokens (Session Management) + +```python +class RefreshToken(Base): + __tablename__ = "refresh_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # Relationships + user: Mapped["User"] = relationship("User", back_populates="refresh_tokens") + +__table_args__ = ( + Index("idx_refresh_tokens_user_active", "user_id", "revoked_at"), # Active tokens per user + Index("idx_refresh_tokens_expires", "expires_at"), # Cleanup expired tokens +) +``` + +**Why this design:** +- `token_hash` stores hashed refresh token (never store plaintext tokens in DB) +- `expires_at` for long-lived but not permanent tokens (e.g., 30 days) +- `revoked_at` enables explicit logout (revoke token) +- `last_used_at` for security monitoring (detect stolen tokens via unusual usage patterns) +- Multiple refresh tokens per user allowed (multi-device support) + +### Modified Existing Tables + +#### projects (Add Ownership) + +```python +# ADD to existing Project model: +owner_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False +) + +owner: Mapped["User"] = relationship("User", back_populates="owned_projects") +members: Mapped[list["ProjectMember"]] = relationship( + "ProjectMember", back_populates="project", cascade="all, delete-orphan" +) +``` + +**Migration strategy:** +- New column `owner_id` with `server_default=1` (assumes first admin user has id=1) +- Run data migration to assign existing projects to the admin user +- After migration, make `owner_id` NOT NULL + +#### requirement_nodes (Add Attribution) + +```python +# ADD to existing RequirementNode model: +created_by_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True +) +updated_by_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True +) + +created_by: Mapped["User | None"] = relationship( + "User", foreign_keys=[created_by_id], back_populates="created_requirements" +) +updated_by: Mapped["User | None"] = relationship( + "User", foreign_keys=[updated_by_id] +) +``` + +**Migration strategy:** +- Both columns nullable (existing data has NULL creator) +- `SET NULL` on user deletion preserves history even if user deleted +- Display "Unknown User" in UI when created_by_id is NULL + +#### requirement_history (Add User Attribution) + +```python +# ADD to existing RequirementHistory model: +user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True +) + +user: Mapped["User | None"] = relationship("User") +``` + +**Migration strategy:** +- Nullable because historical edits have no user attribution +- New history entries always have user_id (enforced in router logic) + +## Integration Patterns + +### Pattern 1: User Context Injection (Dependency Injection) + +**What:** Extract authenticated user from JWT and provide as dependency to route handlers. + +**Implementation:** + +```python +# In auth.py +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from pydantic import BaseModel + +security = HTTPBearer() + +class UserContext(BaseModel): + user_id: int + email: str + is_admin: bool + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> UserContext: + """Extract user from JWT access token.""" + try: + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + user_id: int = payload.get("sub") + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid token") + except JWTError: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + # Verify user exists and is active + user = db.query(User).filter(User.id == user_id, User.is_active == True).first() + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + return UserContext(user_id=user.id, email=user.email, is_admin=user.is_admin) + +# Usage in routes: +@router.get("/api/projects") +def list_projects( + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_user) +): + # current_user available here + pass +``` + +**Why this works:** +- FastAPI's dependency injection runs get_current_user before route handler +- Raises HTTPException if token invalid (automatic 401 response) +- UserContext is immutable (Pydantic model) preventing tampering +- No middleware needed—explicit dependencies are clearer + +### Pattern 2: RBAC Authorization (Permission Checking) + +**What:** Check user's role on a project before allowing operations. + +**Implementation:** + +```python +# In auth.py +from functools import wraps +from typing import Callable + +class PermissionDenied(HTTPException): + def __init__(self): + super().__init__(status_code=403, detail="Insufficient permissions") + +def require_project_access( + project_id: int, + required_role: ProjectMemberRole, + db: Session, + current_user: UserContext +) -> ProjectMember: + """ + Check if user has required role on project. + Returns ProjectMember if authorized, raises 403 otherwise. + + Role hierarchy: owner > editor > viewer + """ + # Global admins bypass all checks + if current_user.is_admin: + return ProjectMember( + project_id=project_id, + user_id=current_user.user_id, + role=ProjectMemberRole.owner + ) + + # Check ownership + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if project.owner_id == current_user.user_id: + return ProjectMember( + project_id=project_id, + user_id=current_user.user_id, + role=ProjectMemberRole.owner + ) + + # Check membership + member = db.query(ProjectMember).filter( + ProjectMember.project_id == project_id, + ProjectMember.user_id == current_user.user_id + ).first() + + if not member: + raise PermissionDenied() + + # Role hierarchy check + role_order = { + ProjectMemberRole.viewer: 1, + ProjectMemberRole.editor: 2, + ProjectMemberRole.owner: 3, + } + + if role_order[member.role] < role_order[required_role]: + raise PermissionDenied() + + return member + +# Usage in routes: +@router.delete("/api/requirements/{req_id}") +def delete_requirement( + req_id: str, + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_user) +): + req = db.query(RequirementNode).filter(RequirementNode.id == req_id).first() + if not req: + raise HTTPException(status_code=404) + + # Check editor access on project + require_project_access( + project_id=req.project_id, + required_role=ProjectMemberRole.editor, + db=db, + current_user=current_user + ) + + # Authorized—proceed with deletion + db.delete(req) + db.commit() +``` + +**Why this works:** +- Explicit permission checks at operation level (not blanket middleware) +- Role hierarchy encoded in single function (DRY principle) +- Global admins bypass for administrative operations +- Project owners inherit full access automatically + +### Pattern 3: Tenant-Scoped Queries (Data Isolation) + +**What:** Automatically filter queries to only return data user has access to. + +**Implementation (Option A: Manual Filtering - Recommended for SQLite):** + +```python +# In routers/projects.py +@router.get("/api/projects", response_model=list[ProjectResponse]) +def list_projects( + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_user) +): + """List all projects accessible to current user.""" + if current_user.is_admin: + # Admins see all projects + projects = db.query(Project).all() + else: + # Users see owned + member projects + owned = db.query(Project).filter(Project.owner_id == current_user.user_id) + member = ( + db.query(Project) + .join(ProjectMember) + .filter(ProjectMember.user_id == current_user.user_id) + ) + projects = owned.union(member).all() + + return projects +``` + +**Implementation (Option B: SQLAlchemy Session Event Hooks - Advanced):** + +```python +# In database.py +from sqlalchemy import event +from sqlalchemy.orm import Session + +class TenantSession(Session): + """Session subclass that auto-filters queries by user_id.""" + def __init__(self, user_id: int | None = None, **kwargs): + super().__init__(**kwargs) + self.user_id = user_id + +@event.listens_for(TenantSession, "do_orm_execute") +def receive_do_orm_execute(execute_state): + """Intercept queries and inject tenant filters.""" + if execute_state.is_select and execute_state.session.user_id: + # Auto-add user_id filter to all queries on tables with user_id column + # (Complex implementation—see SQLAlchemy docs) + pass + +# In dependency: +def get_tenant_db(current_user: UserContext = Depends(get_current_user)): + db = TenantSession(user_id=current_user.user_id, bind=engine) + try: + yield db + finally: + db.close() +``` + +**Recommendation:** Use **Option A (Manual Filtering)** for this project because: +- SQLite doesn't support Row Level Security like PostgreSQL +- Explicit filters are easier to understand and debug +- Session event hooks add complexity and can break with ORM updates +- Project has moderate query count—manual filters are manageable + +### Pattern 4: Refresh Token Rotation (Session Security) + +**What:** Short-lived access tokens (15min) with long-lived refresh tokens (30 days). + +**Implementation:** + +```python +# In auth.py +ACCESS_TOKEN_EXPIRE_MINUTES = 15 +REFRESH_TOKEN_EXPIRE_DAYS = 30 + +def create_access_token(user_id: int) -> str: + """Short-lived token for API requests.""" + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + payload = {"sub": user_id, "exp": expire, "type": "access"} + return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) + +def create_refresh_token(db: Session, user_id: int) -> str: + """Long-lived token for obtaining new access tokens.""" + token = secrets.token_urlsafe(32) # Cryptographically secure random + token_hash = hashlib.sha256(token.encode()).hexdigest() + + expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + + db_token = RefreshToken( + user_id=user_id, + token_hash=token_hash, + expires_at=expires_at + ) + db.add(db_token) + db.commit() + + return token # Return plaintext (only time it's visible) + +@router.post("/auth/refresh", response_model=TokenResponse) +def refresh_access_token( + refresh_token: str, + db: Session = Depends(get_db) +): + """Exchange refresh token for new access token.""" + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + + db_token = db.query(RefreshToken).filter( + RefreshToken.token_hash == token_hash, + RefreshToken.revoked_at.is_(None), + RefreshToken.expires_at > datetime.now(timezone.utc) + ).first() + + if not db_token: + raise HTTPException(status_code=401, detail="Invalid or expired refresh token") + + # Update last used timestamp + db_token.last_used_at = datetime.now(timezone.utc) + db.commit() + + # Generate new access token + access_token = create_access_token(db_token.user_id) + + return TokenResponse(access_token=access_token) + +@router.post("/auth/logout") +def logout( + refresh_token: str, + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_user) +): + """Revoke refresh token (logout).""" + token_hash = hashlib.sha256(refresh_token.encode()).hexdigest() + + db_token = db.query(RefreshToken).filter( + RefreshToken.token_hash == token_hash, + RefreshToken.user_id == current_user.user_id + ).first() + + if db_token: + db_token.revoked_at = datetime.now(timezone.utc) + db.commit() + + return {"message": "Logged out"} +``` + +**Frontend integration (Angular):** + +```typescript +// In auth.interceptor.ts +intercept(req: HttpRequest, next: HttpHandler): Observable> { + return next.handle(this.addToken(req)).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401 && this.getRefreshToken()) { + return this.handle401Error(req, next); + } + return throwError(() => error); + }) + ); +} + +private handle401Error(req: HttpRequest, next: HttpHandler) { + if (!this.isRefreshing) { + this.isRefreshing = true; + this.refreshTokenSubject.next(null); + + return this.authService.refreshToken().pipe( + switchMap((token: any) => { + this.isRefreshing = false; + this.refreshTokenSubject.next(token.access_token); + return next.handle(this.addToken(req)); + }), + catchError((err) => { + this.isRefreshing = false; + this.authService.logout(); + return throwError(() => err); + }) + ); + } + + return this.refreshTokenSubject.pipe( + filter(token => token !== null), + take(1), + switchMap(() => next.handle(this.addToken(req))) + ); +} +``` + +**Why this works:** +- Access tokens short-lived (minimal damage if stolen) +- Refresh tokens stored in httpOnly cookies or secure storage (harder to steal) +- Token rotation prevents replay attacks +- Explicit revocation enables secure logout + +### Pattern 5: Invite-Only Registration Flow + +**What:** Only users with valid invite tokens can register. + +**Implementation:** + +```python +# In auth.py +@router.post("/auth/invites", dependencies=[Depends(get_current_user)]) +def create_invite( + email: str, + db: Session = Depends(get_db), + current_user: UserContext = Depends(get_current_user) +): + """Create invite token (authenticated users only).""" + # Check if email already registered + if db.query(User).filter(User.email == email).first(): + raise HTTPException(status_code=400, detail="User already exists") + + # Check for unused invite + existing = db.query(InviteToken).filter( + InviteToken.email == email, + InviteToken.used_at.is_(None), + InviteToken.expires_at > datetime.now(timezone.utc) + ).first() + + if existing: + raise HTTPException(status_code=400, detail="Invite already sent") + + # Generate token + token = secrets.token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(days=7) + + invite = InviteToken( + token=token, + email=email, + created_by_id=current_user.user_id, + expires_at=expires_at + ) + db.add(invite) + db.commit() + + # TODO: Send email with registration link + # send_invite_email(email, f"https://app.example.com/register?token={token}") + + return {"token": token, "expires_at": expires_at} + +@router.post("/auth/register", response_model=TokenResponse) +def register( + invite_token: str, + email: str, + password: str, + full_name: str | None, + db: Session = Depends(get_db) +): + """Register new user with invite token.""" + # Validate invite + invite = db.query(InviteToken).filter( + InviteToken.token == invite_token, + InviteToken.email == email, + InviteToken.used_at.is_(None), + InviteToken.expires_at > datetime.now(timezone.utc) + ).first() + + if not invite: + raise HTTPException(status_code=400, detail="Invalid or expired invite") + + # Check if user exists + if db.query(User).filter(User.email == email).first(): + raise HTTPException(status_code=400, detail="User already exists") + + # Create user + hashed_password = get_password_hash(password) + user = User( + email=email, + hashed_password=hashed_password, + full_name=full_name + ) + db.add(user) + + # Mark invite as used + invite.used_at = datetime.now(timezone.utc) + invite.used_by_id = user.id + + db.commit() + + # Generate tokens + access_token = create_access_token(user.id) + refresh_token = create_refresh_token(db, user.id) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token + ) +``` + +**Why this works:** +- Email scoped invites prevent token reuse +- Expiration limits token lifespan +- Audit trail tracks who invited whom +- One-time use prevents invite sharing + +## Data Flow Changes + +### Before (v1.0 - Single Password) + +``` +User enters password + ↓ +POST /api/auth/login {password} + ↓ +Backend validates against single PASSWORD env var + ↓ +If valid: JWT created with {exp} only + ↓ +Frontend stores token in localStorage + ↓ +All API requests include: Authorization: Bearer + ↓ +verify_token() checks JWT signature and expiration + ↓ +If valid: route handler executes (no user context) +``` + +### After (v2.0 - User Accounts) + +``` +User enters email + password + ↓ +POST /api/auth/login {email, password} + ↓ +Backend queries users table by email + ↓ +Verify hashed_password with bcrypt + ↓ +If valid: Generate access_token + refresh_token + ↓ +Frontend stores both tokens (localStorage or httpOnly cookie) + ↓ +API requests include: Authorization: Bearer + ↓ +get_current_user() extracts user_id from JWT + ↓ +Queries users table to verify user still active + ↓ +Returns UserContext(user_id, email, is_admin) + ↓ +Route handler receives current_user dependency + ↓ +Permission checks against project_members table + ↓ +Queries auto-filtered by user's accessible projects + ↓ +Write operations tag created_by_id / updated_by_id +``` + +### New: Token Refresh Flow + +``` +Access token expires (15min) + ↓ +Frontend receives 401 on API request + ↓ +Interceptor catches 401, calls POST /api/auth/refresh {refresh_token} + ↓ +Backend validates refresh_token (hash lookup in DB) + ↓ +Checks: not revoked, not expired, user active + ↓ +If valid: Generate new access_token (refresh_token unchanged) + ↓ +Updates last_used_at timestamp on refresh_token + ↓ +Returns new access_token + ↓ +Frontend retries original request with new token + ↓ +(Transparent to user—no re-login needed) +``` + +## Migration Strategy (Backwards Compatibility) + +### Phase 1: Add New Tables (Non-Breaking) + +```python +# Alembic migration: add users, project_members, invite_tokens, refresh_tokens +# No existing data affected—tables are empty initially +``` + +### Phase 2: Add Columns with Nullable (Non-Breaking) + +```python +# Alembic migration: +# - ALTER TABLE projects ADD COLUMN owner_id INTEGER REFERENCES users(id) +# - ALTER TABLE requirement_nodes ADD COLUMN created_by_id INTEGER REFERENCES users(id) +# - ALTER TABLE requirement_nodes ADD COLUMN updated_by_id INTEGER REFERENCES users(id) +# - ALTER TABLE requirement_history ADD COLUMN user_id INTEGER REFERENCES users(id) + +# All columns nullable initially—existing data has NULL +``` + +### Phase 3: Seed Default Admin User + +```python +# Data migration script: +default_user = User( + id=1, + email="admin@localhost", + hashed_password=get_password_hash(os.environ.get("ADMIN_PASSWORD", "changeme")), + full_name="System Admin", + is_admin=True +) +db.add(default_user) + +# Assign all existing projects to admin +db.execute( + update(Project).values(owner_id=1) +) + +# Mark all existing requirements as created by admin +db.execute( + update(RequirementNode) + .where(RequirementNode.created_by_id.is_(None)) + .values(created_by_id=1) +) + +db.commit() +``` + +### Phase 4: Deploy New Auth Endpoints (Dual Auth) + +```python +# Keep old /api/auth/login {password} working +# Add new /api/auth/login {email, password} +# Frontend updated to use new endpoint +# Old endpoint deprecated but functional (gradual migration) +``` + +### Phase 5: Make owner_id NOT NULL (Breaking) + +```python +# After confirming all projects have owner_id: +# Alembic migration: +# - ALTER TABLE projects ALTER COLUMN owner_id SET NOT NULL +``` + +### Rollback Plan + +If issues discovered after deployment: +1. **Revert code:** Old auth.py still validates tokens (SECRET_KEY unchanged) +2. **Database intact:** New columns nullable—old queries unaffected +3. **No data loss:** All existing data preserved with owner_id=1 + +## Build Order (Recommended Sequence) + +### Milestone 1: Authentication Foundation + +**Goal:** Users can register and login with email/password. + +1. Create `User` model and table +2. Create `InviteToken` model and table +3. Create `RefreshToken` model and table +4. Implement password hashing (passlib + bcrypt) +5. Add `/auth/register` endpoint (with invite token validation) +6. Add `/auth/login` endpoint (email + password → tokens) +7. Add `/auth/refresh` endpoint (refresh token → new access token) +8. Add `/auth/logout` endpoint (revoke refresh token) +9. Update `get_current_user()` dependency +10. Seed default admin user in migration + +**Test:** User can register with invite, login, and receive valid tokens. + +### Milestone 2: Project Ownership + +**Goal:** Projects belong to users, users see only their projects. + +1. Add `owner_id` column to `projects` (nullable) +2. Update project creation to set `owner_id = current_user.user_id` +3. Update `GET /api/projects` to filter by ownership +4. Update `GET /api/requirements` to filter by project ownership +5. Data migration: Assign existing projects to admin +6. Make `owner_id` NOT NULL + +**Test:** User sees only projects they own; other users' projects hidden. + +### Milestone 3: RBAC (Project Sharing) + +**Goal:** Project owners can invite collaborators with roles. + +1. Create `ProjectMember` model and table +2. Add `POST /api/projects/{id}/members` (owner adds member) +3. Add `GET /api/projects/{id}/members` (list members) +4. Add `DELETE /api/projects/{id}/members/{user_id}` (remove member) +5. Implement `require_project_access()` dependency +6. Update all requirement routes to check RBAC +7. Add role field to project list response (user's role per project) + +**Test:** Owner invites editor; editor can modify requirements but not delete project. + +### Milestone 4: Edit Attribution + +**Goal:** History tracks who made each change. + +1. Add `created_by_id`, `updated_by_id` to `requirement_nodes` +2. Add `user_id` to `requirement_history` +3. Update requirement creation to set `created_by_id` +4. Update requirement updates to set `updated_by_id` +5. Update history creation to set `user_id` +6. Update UI to display editor names in history + +**Test:** History panel shows "Edited by Alice" instead of generic timestamps. + +### Milestone 5: Invite System + +**Goal:** Users can invite others to join the platform. + +1. Add `POST /api/auth/invites` (create invite token) +2. Add `GET /api/auth/invites` (list my sent invites) +3. Add invite email template +4. Integrate email sending (SMTP or service like Mailgun) +5. Update registration flow to send welcome email + +**Test:** User creates invite; recipient registers successfully; both users see each other's projects if shared. + +## Anti-Patterns to Avoid + +### Anti-Pattern 1: Trusting Client-Provided user_id + +**What people do:** +```python +@router.post("/api/requirements") +def create_requirement(data: RequirementCreate, user_id: int): # ❌ DANGEROUS + node = RequirementNode(**data.dict(), created_by_id=user_id) + # Attacker can impersonate anyone by sending any user_id +``` + +**Why it's wrong:** Clients can lie. Attacker sends `user_id=1` to impersonate admin. + +**Do this instead:** +```python +@router.post("/api/requirements") +def create_requirement( + data: RequirementCreate, + current_user: UserContext = Depends(get_current_user) # ✅ Server extracts from JWT +): + node = RequirementNode(**data.dict(), created_by_id=current_user.user_id) +``` + +### Anti-Pattern 2: Global Query Filters in Middleware + +**What people do:** +```python +# Middleware that silently filters all queries by user_id +@app.middleware("http") +async def tenant_filter_middleware(request, call_next): + # Inject user_id filter into all SQLAlchemy queries + # (Complex session hook magic) +``` + +**Why it's wrong:** +- Hidden behavior—hard to debug ("why is this query returning nothing?") +- Breaks when you need cross-tenant operations (e.g., admin views) +- SQLAlchemy session state leaks between requests +- Fails silently if middleware doesn't run (security vulnerability) + +**Do this instead:** +```python +# Explicit filtering in route handlers +@router.get("/api/projects") +def list_projects(current_user: UserContext = Depends(get_current_user), db: Session = Depends(get_db)): + return db.query(Project).filter( + (Project.owner_id == current_user.user_id) | + (Project.members.any(ProjectMember.user_id == current_user.user_id)) + ).all() +``` + +### Anti-Pattern 3: Storing Refresh Tokens in localStorage + +**What people do:** +```typescript +// Frontend stores refresh token in localStorage +localStorage.setItem('refresh_token', refreshToken); // ❌ VULNERABLE TO XSS +``` + +**Why it's wrong:** +- XSS attacks can read localStorage and steal refresh tokens +- Refresh tokens are long-lived (30 days)—high-value targets + +**Do this instead:** +```typescript +// Store refresh token in httpOnly cookie (set by backend) +// Backend response: +Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Strict + +// OR store in secure storage with encryption (mobile apps) +// Access tokens in memory only (SessionStorage acceptable) +sessionStorage.setItem('access_token', accessToken); // ✅ Cleared on tab close +``` + +### Anti-Pattern 4: No Token Expiration Validation + +**What people do:** +```python +def verify_token(token: str): + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload["sub"] # ❌ Doesn't check if user still exists/active +``` + +**Why it's wrong:** +- Deleted users can still authenticate (token valid until expiration) +- Deactivated accounts remain accessible +- No way to revoke access immediately + +**Do this instead:** +```python +def get_current_user(credentials = Depends(security), db: Session = Depends(get_db)): + payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload["sub"] + + user = db.query(User).filter(User.id == user_id, User.is_active == True).first() # ✅ Verify user active + if not user: + raise HTTPException(status_code=401, detail="User not found or inactive") + + return UserContext(user_id=user.id, email=user.email, is_admin=user.is_admin) +``` + +### Anti-Pattern 5: CASCADE DELETE Without Considering Foreign Keys + +**What people do:** +```python +# Delete user without considering owned projects +db.delete(user) # ❌ Fails if projects reference this user +db.commit() +``` + +**Why it's wrong:** +- Foreign key constraint violations +- Data loss if CASCADE propagates unexpectedly +- No clear ownership transfer + +**Do this instead:** +```python +# Before deleting user, reassign or delete owned resources +def delete_user(user_id: int, db: Session): + # Transfer project ownership to another admin + admin = db.query(User).filter(User.is_admin == True, User.id != user_id).first() + if not admin: + raise HTTPException(status_code=400, detail="Cannot delete last admin") + + db.execute( + update(Project) + .where(Project.owner_id == user_id) + .values(owner_id=admin.id) + ) + + # Now safe to delete user (SET NULL handles created_by/updated_by) + user = db.query(User).filter(User.id == user_id).first() + db.delete(user) + db.commit() +``` + +## Scaling Considerations + +### Single-Writer Limitation (SQLite) + +**Problem:** SQLite uses a single write lock. With many concurrent users editing requirements, write contention increases. + +**Mitigation:** +- Enable WAL mode: `PRAGMA journal_mode=WAL;` (allows concurrent reads during writes) +- Use connection pooling with conservative pool size (avoid lock queue buildup) +- Consider read replicas for queries (projects, stats dashboards) +- At ~50 concurrent writers, plan migration to PostgreSQL + +**When to migrate to PostgreSQL:** +- **User count:** >100 active users (>50 concurrent writers) +- **Data size:** >10GB database +- **Features needed:** Full-text search, advanced RBAC (Row Level Security), JSON columns +- **Cost:** PostgreSQL adds operational complexity—delay until necessary + +### Token Cleanup (Garbage Collection) + +**Problem:** Expired refresh tokens and invite tokens accumulate in database. + +**Solution:** +```python +# Scheduled task (run daily via cron or Celery) +def cleanup_expired_tokens(db: Session): + now = datetime.now(timezone.utc) + + # Delete expired refresh tokens + db.query(RefreshToken).filter( + RefreshToken.expires_at < now + ).delete() + + # Delete used or expired invites older than 30 days + cutoff = now - timedelta(days=30) + db.query(InviteToken).filter( + (InviteToken.used_at < cutoff) | (InviteToken.expires_at < now) + ).delete() + + db.commit() +``` + +### Query Performance (Indexing) + +**Critical indexes added:** +- `users.email` (unique, login queries) +- `projects.owner_id` (filter by owner) +- `project_members(project_id, user_id)` (composite PK, bidirectional lookups) +- `requirement_nodes.project_id` (filter requirements by project) +- `refresh_tokens.token_hash` (lookup during refresh) +- `invite_tokens.email` (check existing invites) + +**Monitor:** Use `EXPLAIN QUERY PLAN` to verify index usage. + +## Security Considerations + +### Password Storage + +- **Hash algorithm:** bcrypt with cost factor 12 (via passlib) +- **Never log passwords** (sanitize logs, error messages) +- **Enforce minimum strength:** 8 characters, mixed case, numbers (frontend + backend validation) + +### Token Security + +- **Access tokens:** Short-lived (15min), stored in memory (SessionStorage acceptable) +- **Refresh tokens:** Long-lived (30 days), httpOnly cookies or encrypted storage +- **Token hashing:** Store only hashed refresh tokens in DB (SHA-256) +- **Secret rotation:** Support multiple SECRET_KEYs for gradual rotation + +### CSRF Protection + +- **Stateless JWTs:** No CSRF risk (no cookies for auth) +- **If using refresh token cookies:** Require SameSite=Strict or CSRF token + +### Rate Limiting + +Add rate limiting to auth endpoints: +```python +# Using slowapi (FastAPI rate limiting) +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) + +@router.post("/auth/login") +@limiter.limit("5/minute") # 5 login attempts per minute per IP +def login(request: Request, ...): + pass +``` + +## Frontend Integration Points + +### Angular Changes Required + +1. **Auth Service:** + - Update login() to send email + password + - Store both access_token and refresh_token + - Implement token refresh logic in HTTP interceptor + - Add getCurrentUser() to fetch user profile + +2. **HTTP Interceptor:** + - Inject access_token in Authorization header + - Catch 401 errors, attempt refresh, retry request + - If refresh fails, redirect to login + +3. **Route Guards:** + - Update AuthGuard to check token validity + - Add RoleGuard for permission-based route protection + - Fetch user profile on app initialization + +4. **UI Components:** + - Add user profile menu (email, logout button) + - Show project role badges (owner/editor/viewer) + - Add project sharing dialog (invite members) + - Show editor attribution in history panel ("Edited by Alice") + +## Sources + +**Multi-Tenancy Patterns:** +- [Introduction to Multi-Tenant Design with FastAPI](https://blog.greeden.me/en/2026/03/10/introduction-to-multi-tenant-design-with-fastapi-practical-patterns-for-tenant-isolation-authorization-database-strategy-and-audit-logs/) (2026 guide, HIGH confidence) +- [GitHub Discussion: How to implement Multi tenancy in FastAPI](https://github.com/fastapi/fastapi/discussions/6056) +- [MergeBoard: Multitenancy with FastAPI, SQLAlchemy and PostgreSQL](https://mergeboard.com/blog/6-multitenancy-fastapi-sqlalchemy-postgresql/) + +**Authentication & RBAC:** +- [Building a Modern User Permission Management System with FastAPI, SQLAlchemy 2.0](https://dev.to/mochafreddo/building-a-modern-user-permission-management-system-with-fastapi-sqlalchemy-and-mariadb-5fp1) (HIGH confidence) +- [FastAPI RBAC - Full Implementation Tutorial](https://www.permit.io/blog/fastapi-rbac-full-implementation-tutorial) +- [JWT in FastAPI, the Secure Way (Refresh Tokens Explained)](https://medium.com/@jagan_reddy/jwt-in-fastapi-the-secure-way-refresh-tokens-explained-f7d2d17b1d17) (Jan 2026) + +**Email Verification & Invites:** +- [User Account Verification Via Email - FastAPI Beyond CRUD](https://dev.to/jod35/user-account-verification-via-email-fastapi-beyond-crud-part-18-15ib) +- [Top 5 authentication solutions for secure FastAPI apps in 2026](https://workos.com/blog/top-authentication-solutions-fastapi-2026) + +**Dependency Injection:** +- [FastAPI Auth with Dependency Injection](https://www.propelauth.com/post/fastapi-auth-with-dependency-injection) +- [How to Use Dependency Injection in FastAPI](https://oneuptime.com/blog/post/2026-02-02-fastapi-dependency-injection/view) (2026) +- [Dependency Injection in FastAPI: 2026 Playbook](https://thelinuxcode.com/dependency-injection-in-fastapi-2026-playbook-for-modular-testable-apis/) + +**SQLite Multi-Tenancy:** +- [Give each of your users their own SQLite database](https://turso.tech/blog/give-each-of-your-users-their-own-sqlite-database) (Database-per-tenant approach) +- [Multi-tenancy - High Performance SQLite](https://highperformancesqlite.com/watch/multi-tenancy) + +**Database Migrations:** +- [How to do the migration in FastAPI](https://mdhvkothari.medium.com/how-to-do-the-migration-in-fastapi-5c53d3880f12) +- [FastAPI with Async SQLAlchemy, SQLModel, and Alembic](https://testdriven.io/blog/fastapi-sqlmodel/) + +--- + +*Architecture research for: req-planner v2.0 Multi-Tenancy & User Accounts* +*Researched: 2026-03-18* +*Confidence: HIGH (Context7 unavailable, but official FastAPI docs + SQLAlchemy 2.0 docs + recent 2026 tutorials provide authoritative patterns)* diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md new file mode 100644 index 0000000..a137179 --- /dev/null +++ b/.planning/research/FEATURES.md @@ -0,0 +1,492 @@ +# Feature Research + +**Domain:** Multi-tenancy, user accounts, and role-based access control +**Researched:** 2026-03-18 +**Confidence:** HIGH + +## Feature Landscape + +### Table Stakes (Users Expect These) + +Features users assume exist. Missing these = product feels incomplete. + +| Feature | Why Expected | Complexity | Notes | +|---------|--------------|------------|-------| +| Individual user accounts | Standard expectation in any multi-user app; shared passwords are deprecated UX | MEDIUM | Email/password auth, JWT tokens (already have JWT infrastructure) | +| Per-user project ownership | Users expect "my projects" vs "shared with me" distinction | MEDIUM | Requires user_id column on projects table, workspace isolation | +| Invite-only registration | Standard pattern for self-hosted tools; prevents open registration abuse | LOW | Invite codes/tokens in database, validation before signup | +| Basic role hierarchy (viewer/editor/admin) | Industry standard in collaborative tools (GitHub, Notion, Figma all use 3-tier) | MEDIUM | Role enum on project_user junction table, permission checks | +| Edit attribution in history | Users expect to see "who changed what" in version history | LOW | Already have history table; add user_id foreign key | +| Project sharing UI | Ability to add collaborators and set their role | MEDIUM | UI for managing project members, permission checks | +| Session management | Users expect to see active sessions, logout from devices | LOW | JWT refresh tokens, token revocation list | +| Password reset flow | Expected security hygiene for email/password auth | MEDIUM | Email service integration, reset tokens, secure workflows | + +### Differentiators (Competitive Advantage) + +Features that set the product apart. Not required, but valuable. + +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| Granular audit trail with rollback | Beyond basic attribution—show full change context and allow per-edit rollback | MEDIUM | Leverage existing history system; add user context to each snapshot | +| Workspace-level dashboards | Per-user analytics showing activity across all their projects | LOW | Aggregate existing project stats by user | +| Inherited permissions on requirement hierarchy | Child requirements inherit parent permissions; reduces management overhead | HIGH | Complex to implement correctly; easy to introduce leaks | +| Anonymous viewer links | Share read-only project views without requiring account | MEDIUM | Signed tokens with expiry, no-auth view endpoints | +| Activity feeds | "What changed since I last logged in" per project | MEDIUM | Requires tracking last_seen timestamps and filtering history | +| Workspace templates | Users can save project configurations as templates for reuse | LOW | Export/import existing project structure | +| Fine-grained permission customization | Beyond viewer/editor/admin—custom permission sets | HIGH | Significantly increases complexity; likely over-engineering for v2 | + +### Anti-Features (Commonly Requested, Often Problematic) + +Features that seem good but create problems. + +| Feature | Why Requested | Why Problematic | Alternative | +|---------|---------------|-----------------|-------------| +| Real-time collaborative editing | "Like Google Docs" — users assume all modern tools have it | Massive architectural lift; requires WebSockets, operational transforms, conflict resolution; SQLite poor fit | Optimistic locking with "edited by X" warnings; auto-refresh on save | +| Organization/team hierarchies | Enterprises expect nested orgs (company → department → team) | Dramatically increases permission model complexity; complicates billing/limits | Single workspace per user; flat project sharing model | +| Social login (OAuth) | Convenient for users, reduces friction | Adds external dependencies; complicates self-hosted deployments; privacy concerns | Email/password sufficient for self-hosted tool; invite-only provides security | +| Fine-grained field-level permissions | "Editor can change description but not acceptance criteria" | Explosion of permission checks; confusing UX; high maintenance burden | Role-based with clear responsibilities; don't split access within entities | +| Public project discovery | "Browse all public projects like GitHub" | Privacy concerns for self-hosted tool; invites spam/abuse | Explicit sharing only; anonymous links for specific cases | +| User-to-user direct messaging | "Chat with collaborators in-app" | Scope creep; users already have Slack/email/Discord | Keep tool focused; collaboration happens in comments/edits | + +## Feature Dependencies + +``` +Individual User Accounts (foundational) + ├──requires──> User Authentication Flow + │ ├──requires──> Login/Logout + │ ├──requires──> Password Reset + │ └──requires──> Session Management + │ + ├──requires──> Per-User Project Ownership + │ ├──requires──> Workspace Isolation (tenant_id pattern) + │ └──requires──> "My Projects" vs "Shared With Me" views + │ + ├──requires──> Edit Attribution + │ ├──enhances──> Existing History System + │ └──requires──> User Foreign Keys on History Table + │ + └──requires──> Invite-Only Registration + ├──requires──> Invite Token Generation + ├──requires──> Invite Validation + └──requires──> Admin Controls + +Role-Based Access Control + ├──requires──> Individual User Accounts (above) + ├──requires──> Project-User Junction Table + │ └──stores──> Role (viewer/editor/admin/owner) + │ + ├──requires──> Permission Enforcement Layer + │ ├──frontend──> Route guards, UI element visibility + │ └──backend──> Endpoint authorization checks + │ + └──enhances──> Project Sharing UI + └──requires──> Member Management Interface + +Project Sharing UI + ├──requires──> Role-Based Access Control (above) + └──requires──> Invite Notification System + +Audit Trail with User Context + ├──requires──> Edit Attribution (above) + └──enhances──> Existing History Visualization +``` + +### Dependency Notes + +- **Individual User Accounts is foundational**: All other features depend on this being implemented first +- **Workspace Isolation must be implemented correctly from the start**: Retrofitting tenant isolation is high-risk for data leaks +- **Edit Attribution enhances existing history**: Already have requirement_history table; adding user_id is low-risk +- **RBAC requires careful sequencing**: Must implement permission checks in backend before exposing sharing UI +- **Invite-only registration can be parallel to RBAC**: These features are independent after user accounts exist + +## MVP Definition + +### Launch With (v2.0) + +Minimum viable multi-tenancy — what's needed to validate the concept. + +- [x] Individual user accounts with email/password authentication — Essential foundation +- [x] Invite-only registration with admin-generated invite codes — Security requirement for self-hosted +- [x] Per-user project ownership with workspace isolation — Core multi-tenancy feature +- [x] Three-tier RBAC (owner/editor/viewer) on projects — Table stakes for collaboration +- [x] Edit attribution in history (user_id on edits) — Minimal audit trail +- [x] Project sharing UI with role assignment — Required to make RBAC usable +- [x] Basic permission enforcement (backend + frontend) — Security requirement +- [x] Password reset flow — Expected security hygiene + +### Add After Validation (v2.x) + +Features to add once core multi-tenancy is working. + +- [ ] Activity feeds per project — Add once users report "losing track of changes" +- [ ] Workspace-level dashboard — Add when users manage 5+ projects +- [ ] Anonymous viewer links — Add when users request "share with client" workflows +- [ ] Granular audit trail with per-edit rollback — Add when attribution proves insufficient +- [ ] Session management UI — Add if users report security concerns with active sessions +- [ ] Workspace templates — Add when users request "clone project structure" + +### Future Consideration (v3+) + +Features to defer until product-market fit is established. + +- [ ] Fine-grained permission customization — Defer until 3-tier RBAC proves insufficient +- [ ] Inherited permissions on hierarchy — Complex; defer until users report permission management overhead +- [ ] Organization/team hierarchies — Defer until serving enterprise customers +- [ ] OAuth/SSO providers — Defer until serving organizations requiring SSO +- [ ] Real-time collaborative editing — Major architectural change; defer until core collaboration proves valuable + +## Feature Prioritization Matrix + +| Feature | User Value | Implementation Cost | Priority | +|---------|------------|---------------------|----------| +| Individual user accounts | HIGH | MEDIUM | P1 | +| Invite-only registration | HIGH | LOW | P1 | +| Per-user project ownership | HIGH | MEDIUM | P1 | +| Three-tier RBAC (owner/editor/viewer) | HIGH | MEDIUM | P1 | +| Edit attribution | HIGH | LOW | P1 | +| Project sharing UI | HIGH | MEDIUM | P1 | +| Permission enforcement layer | HIGH | HIGH | P1 | +| Password reset flow | MEDIUM | MEDIUM | P1 | +| Activity feeds | MEDIUM | MEDIUM | P2 | +| Workspace dashboard | MEDIUM | LOW | P2 | +| Anonymous viewer links | MEDIUM | MEDIUM | P2 | +| Granular audit trail | LOW | MEDIUM | P2 | +| Session management UI | LOW | LOW | P2 | +| Workspace templates | LOW | LOW | P2 | +| Fine-grained permissions | LOW | HIGH | P3 | +| Inherited permissions | MEDIUM | HIGH | P3 | +| Organization hierarchies | LOW | HIGH | P3 | +| OAuth/SSO | LOW | HIGH | P3 | +| Real-time collaboration | HIGH | VERY HIGH | P3 | + +**Priority key:** +- P1: Must have for v2.0 launch — core multi-tenancy +- P2: Should have for v2.x — enhances collaboration +- P3: Nice to have for v3+ — future consideration + +## Expected Behavior and UX Patterns + +### Invite-Only Registration + +**Standard Flow:** +1. Admin generates invite code/link from admin panel +2. Invite stored in database with: code (UUID), email (optional), created_by, expires_at, used (boolean) +3. Registration page requires valid invite code before showing signup form +4. After successful signup, invite marked as used (single-use) or incremented (multi-use) +5. Expired/invalid codes show clear error messages + +**UX Patterns (2026):** +- **Invite links preferred over codes**: Users click link → auto-fills invite code → shows signup form +- **Email pre-fill**: If invite includes email, pre-populate signup form (can't be changed) +- **Context in email**: Invitation email shows who invited them and to which workspace +- **Clear expiry**: Show "This invite expires in X days" prominently +- **Admin controls**: Dashboard showing pending/used/expired invites + +**Sources:** +- [User Onboarding Strategies in a B2B SaaS Application](https://auth0.com/blog/user-onboarding-strategies-b2b-saas/) +- [How to onboard invited users and fast-track user engagement](https://www.appcues.com/blog/user-onboarding-strategies-invited-users) + +### Per-User Project Ownership + +**Standard Patterns:** +1. **Shared database with tenant_id**: All users share same tables; user_id column differentiates ownership +2. **Workspace views**: "My Projects" (owned), "Shared With Me" (collaborator), optionally "All Projects" (admin) +3. **Default visibility**: Projects private by default; owner explicitly shares +4. **Transfer ownership**: Admin capability to reassign project owner +5. **Orphaned projects**: Handle deleted user accounts (reassign or archive) + +**Data Model:** +```sql +projects: + - owner_id (FK to users) + - created_by (FK to users) + - updated_by (FK to users) + +project_users (junction): + - project_id (FK to projects) + - user_id (FK to users) + - role (enum: owner, editor, viewer) + - added_by (FK to users) + - added_at (timestamp) +``` + +**Critical Security Pattern:** +- Every query MUST include `WHERE user_id = ? OR EXISTS (SELECT 1 FROM project_users WHERE project_id = ? AND user_id = ?)` +- Enforce at ORM level or middleware layer, not per-endpoint (reduces chance of missing checks) + +**Sources:** +- [The developer's guide to SaaS multi-tenant architecture — WorkOS](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture) +- [Tenant Data Isolation: 5 Patterns That Actually Work](https://propelius.tech/blogs/tenant-data-isolation-patterns-and-anti-patterns/) + +### Role-Based Access Control (Viewer/Editor/Admin) + +**Industry Standard Three-Tier Model:** + +| Role | Read | Create/Edit | Delete | Manage Members | Transfer Ownership | +|------|------|-------------|--------|----------------|--------------------| +| **Owner** | ✓ | ✓ | ✓ | ✓ | ✓ | +| **Editor** | ✓ | ✓ | ✓ (own edits) | ✗ | ✗ | +| **Viewer** | ✓ | ✗ | ✗ | ✗ | ✗ | + +**Implementation Pattern:** +```python +# Backend: Permission decorator +@require_project_permission(Permission.EDIT) +def update_requirement(project_id, requirement_id, data): + # Only called if user has editor+ role + pass + +# Frontend: Structural directives + +``` + +**UX Expectations:** +- **Graceful degradation**: Viewers see read-only interface, not error messages +- **Clear role badges**: Show user's role in project header ("You are an Editor") +- **Contextual actions**: Only show actions user can perform (hide "Delete" for viewers) +- **Permission feedback**: If user somehow triggers unauthorized action, show clear explanation + +**Common Mistakes to Avoid:** +- **Authorization only on frontend**: Always enforce on backend; frontend is convenience only +- **Inconsistent enforcement**: Missing permission checks on some endpoints creates security holes +- **Role explosion**: Don't create custom roles too early; three tiers sufficient for most cases +- **Cache-based authorization**: Store role in user-controllable location (localStorage) for UI only; always verify on backend + +**Sources:** +- [How to Build a Role-Based Access Control Layer](https://www.osohq.com/learn/rbac-role-based-access-control) +- [Role-Based Access Control Best Practices for 2026](https://www.techprescient.com/blogs/role-based-access-control-best-practices/) +- [Guide to sharing and permissions – Figma Learn](https://help.figma.com/hc/en-us/articles/1500007609322-Guide-to-sharing-and-permissions) + +### Edit Attribution + +**Expected Behavior:** +1. Every edit records: user_id, timestamp, before/after state +2. History view shows: "John Doe edited requirement title at 2:34 PM" +3. Hover/click shows full context: what changed, by whom, when +4. Filter history: "Show only my edits" or "Show edits by John" + +**Google Docs Pattern (Gold Standard):** +- **Show Editors feature**: Right-click text → "Show Editors" → see who edited that specific range +- **Named versions**: Users can create named checkpoints ("Before client review") +- **Continuous recording**: Every change captured automatically (no manual "save version") +- **Real-time attribution**: See colored cursors/highlights showing who's editing what + +**Adapted for Non-Real-Time:** +- Display "Last edited by X at Y" on each requirement card +- History panel shows chronological list with user avatars +- Diff view highlights changes with attribution ("Added by John", "Removed by Jane") +- Filter/search history by user + +**Implementation Notes:** +- Already have requirement_history table +- Add user_id foreign key to requirement_history +- Add created_by, updated_by to requirement_nodes +- Frontend displays user info from history records + +**Sources:** +- [Google Workspace Updates: "Show Editors" provides more context on changes made in Google Docs](https://workspaceupdates.googleblog.com/2021/05/view-more-context-on-google-docs-edits-with-show-editors.html) +- [How to See Version History in Google Docs](https://www.geeksforgeeks.org/websites-apps/how-to-see-version-history-in-google-docs/) + +### Project Sharing UI + +**Standard Patterns:** +1. **Share button**: Prominent in project header +2. **Member list**: Shows current collaborators with roles +3. **Add member flow**: + - Input: Email or username + - Select role: Owner/Editor/Viewer (with tooltips explaining each) + - Optional: Add message to invitation + - Send: Generates notification and adds to project +4. **Role modification**: Click role dropdown → change role → confirm +5. **Remove member**: Click X or "Remove" → confirm dialog → remove access + +**GitHub Pattern:** +- Settings → Collaborators → "Add people" → username → role → "Add" +- Shows pending invitations separately from active members +- Email notification when invited + +**Notion Pattern:** +- "Share" button → modal with member list +- Inline role editing (click role → dropdown) +- "Copy link" for anonymous sharing +- Shows "X has access via Team Y" (inherited permissions) + +**Figma Pattern:** +- Share button → modal showing team/project/file hierarchy +- Link sharing with permission level attached to link +- "Anyone with link can view/edit" + +**Recommended Approach:** +- Hybrid: GitHub-style explicit invitations + Notion-style inline role editing +- No link-based sharing in v2.0 (add in v2.x as anonymous viewer links) +- Clear visual distinction between owner (1 per project) and collaborators + +**Sources:** +- [Guide to sharing and permissions – Figma Learn](https://help.figma.com/hc/en-us/articles/1500007609322-Guide-to-sharing-and-permissions) +- [How to Add Collaborators in GitHub](https://www.rapidnative.com/blogs/how-to-add-collaborators-in-github) + +## Complexity on Existing Features + +### Impact on Existing Edit History + +**Current State:** +- requirement_history table stores snapshots (before/after JSON) +- No user attribution — assumes single user + +**Required Changes:** +- Add user_id column (FK to users) +- Migrate existing records: set user_id to admin or mark as "system" +- Update create_history_record() to include current_user_id +- Update history display to show user info + +**Complexity: LOW** — Additive change, doesn't break existing functionality + +### Impact on Existing Graph Visualization + +**Current State:** +- Graph shows all requirements in project +- No permission-based filtering + +**Required Changes:** +- Filter requirements based on project access (user is owner/editor/viewer) +- Show read-only indicators for viewers +- Disable edit actions (drag, delete, create) for viewers +- Show permission indicators in detail panel + +**Complexity: MEDIUM** — Requires permission checks in graph component, but no architectural changes + +### Impact on Existing CRUD Operations + +**Current State:** +- No user context on create/update/delete +- No ownership checks + +**Required Changes:** +- Add user_id to CREATE operations (set owner) +- Add permission checks to UPDATE operations (is editor+?) +- Add permission checks to DELETE operations (is owner or editor with own edits?) +- Add created_by, updated_by columns to requirement_nodes +- Migrate existing records: set created_by to admin or null + +**Complexity: MEDIUM** — Every endpoint needs permission middleware, but follows consistent pattern + +### Impact on Existing Dashboard + +**Current State:** +- Shows all projects globally +- Statistics aggregate all data + +**Required Changes:** +- Filter projects by ownership + shared access +- Separate "My Projects" and "Shared With Me" views +- Per-project statistics respect permissions +- Add "Collaborators" count to project cards + +**Complexity: LOW** — Mostly UI changes, backend already has project filtering + +### Impact on Database Schema + +**New Tables Required:** +```sql +users ( + id INTEGER PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT, + created_at TIMESTAMP, + last_login TIMESTAMP +) + +invite_codes ( + id INTEGER PRIMARY KEY, + code TEXT UNIQUE NOT NULL, + email TEXT, -- optional: pre-assign to email + created_by INTEGER REFERENCES users(id), + expires_at TIMESTAMP, + used BOOLEAN DEFAULT FALSE, + used_by INTEGER REFERENCES users(id), + used_at TIMESTAMP +) + +project_users ( + id INTEGER PRIMARY KEY, + project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + role TEXT CHECK(role IN ('owner', 'editor', 'viewer')), + added_by INTEGER REFERENCES users(id), + added_at TIMESTAMP, + UNIQUE(project_id, user_id) +) +``` + +**Modified Tables:** +```sql +projects: + + owner_id INTEGER REFERENCES users(id) + + created_by INTEGER REFERENCES users(id) + + updated_by INTEGER REFERENCES users(id) + +requirement_nodes: + + created_by INTEGER REFERENCES users(id) + + updated_by INTEGER REFERENCES users(id) + +requirement_history: + + user_id INTEGER REFERENCES users(id) + +graph_layouts: + + user_id INTEGER REFERENCES users(id) -- per-user layouts +``` + +**Migration Strategy:** +1. Create users table first +2. Create admin user (id=1) +3. Add foreign key columns to existing tables (nullable initially) +4. Set all existing records to admin user (id=1) +5. Make columns NOT NULL after backfill +6. Create invite_codes and project_users tables + +**Complexity: MEDIUM** — Careful sequencing required, but no data loss risk if done correctly + +## Sources + +### UX and Design Patterns +- [Best Sign Up Flows (2026): 15 UX Examples That Convert](https://www.eleken.co/blog-posts/sign-up-flow) +- [Login & Signup UX: The 2025 Guide to Best Practices](https://www.authgear.com/post/login-signup-ux-guide) +- [Designing an intuitive user flow for inviting teammates](https://pageflows.com/resources/invite-teammates-user-flow/) + +### RBAC and Authorization +- [How to Build a Role-Based Access Control Layer](https://www.osohq.com/learn/rbac-role-based-access-control) +- [Role-Based Access Control Best Practices for 2026](https://www.techprescient.com/blogs/role-based-access-control-best-practices/) +- [Role-based Access Control: Five Common Authorization Patterns](https://thenewstack.io/role-based-access-control-five-common-authorization-patterns/) +- [Best Practices for Multi-Tenant Authorization](https://www.permit.io/blog/best-practices-for-multi-tenant-authorization) + +### Multi-Tenancy Architecture +- [The developer's guide to SaaS multi-tenant architecture — WorkOS](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture) +- [Multi-Tenant SaaS Architecture: Complete Guide](https://codeboxr.com/multi-tenant-saas-architecture-complete-guide-models-design-patterns-and-scaling-strategy/) +- [Data Isolation in Multi-Tenant SaaS](https://redis.io/blog/data-isolation-multi-tenant-saas/) +- [Tenant Data Isolation: 5 Patterns That Actually Work](https://propelius.tech/blogs/tenant-data-isolation-patterns-and-anti-patterns/) + +### Security and Data Isolation +- [Preventing Cross-Tenant Data Leakage in Multi-Tenant SaaS Systems](https://agnitestudio.com/blog/preventing-cross-tenant-leakage/) +- [Multi-Tenant Leakage: When "Row-Level Security" Fails in SaaS](https://medium.com/@instatunnel/multi-tenant-leakage-when-row-level-security-fails-in-saas-da25f40c788c) +- [Tenant Isolation in Multi-Tenant Systems](https://securityboulevard.com/2025/12/tenant-isolation-in-multi-tenant-systems-architecture-identity-and-security/) +- [Access control vulnerabilities and privilege escalation](https://portswigger.net/web-security/access-control) + +### Audit Trails and Attribution +- [What Is an Audit Trail? Definition and Best Practices](https://trullion.com/blog/audit-trail-guide/) +- [10 Essential Audit Trail Best Practices for 2026](https://signal.opshub.me/audit-trail-best-practices/) +- [Google Workspace Updates: "Show Editors" in Google Docs](https://workspaceupdates.googleblog.com/2021/05/view-more-context-on-google-docs-edits-with-show-editors.html) +- [How to See Version History in Google Docs](https://www.geeksforgeeks.org/websites-apps/how-to-see-version-history-in-google-docs/) + +### User Onboarding +- [User Onboarding Strategies in a B2B SaaS Application](https://auth0.com/blog/user-onboarding-strategies-b2b-saas/) +- [How to onboard invited users and fast-track user engagement](https://www.appcues.com/blog/user-onboarding-strategies-invited-users) +- [SaaS Onboarding Flows That Actually Convert in 2026](https://www.saasui.design/blog/saas-onboarding-flows-that-actually-convert-2026) + +### Collaboration Patterns +- [Guide to sharing and permissions – Figma Learn](https://help.figma.com/hc/en-us/articles/1500007609322-Guide-to-sharing-and-permissions) +- [How to Add Collaborators in GitHub](https://www.rapidnative.com/blogs/how-to-add-collaborators-in-github) + +--- +*Feature research for: req-planner v2.0 Multi-Tenancy & User Accounts* +*Researched: 2026-03-18* diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md new file mode 100644 index 0000000..7771f4d --- /dev/null +++ b/.planning/research/PITFALLS.md @@ -0,0 +1,443 @@ +# Pitfalls Research + +**Domain:** Adding multi-tenancy, user accounts, invite systems, and RBAC to existing single-user FastAPI+SQLite app +**Researched:** 2026-03-18 +**Confidence:** MEDIUM-HIGH + +## Critical Pitfalls + +### Pitfall 1: Forgetting tenant_id Filters in Queries + +**What goes wrong:** +Endpoints return all data across all users/tenants instead of just the authenticated user's data. A single missing `.filter(owner_id == current_user.id)` exposes another user's projects, requirements, or edit history. + +**Why it happens:** +When migrating from single-user to multi-user, the existing codebase has no tenant scoping. Every SQLAlchemy query was written assuming global access. Developers update auth but forget to audit and update every single query in the application. + +**How to avoid:** +- Create base query functions that automatically inject tenant filters +- Use SQLAlchemy events or custom query classes to enforce tenant scoping +- Never trust frontend filtering — always enforce at backend/database level +- Code review checklist: "Does this query filter by user/tenant?" +- Write integration tests that attempt cross-tenant access + +**Warning signs:** +- "I can see another user's data in the API response" +- Tests pass in isolation but fail when multiple users exist +- Postman/curl requests with different tokens return overlapping data + +**Phase to address:** +Phase 1 (Database Schema & User Model) — Establish the tenant scoping pattern from the start, create helper functions/base classes that enforce it. + +--- + +### Pitfall 2: SQLite Foreign Key Constraint Failures During Migration + +**What goes wrong:** +Adding `user_id` foreign keys to existing tables (projects, requirements, history, layouts) causes migration failures or silently drops data. SQLite's ALTER TABLE limitations force table recreation, and foreign key constraints can cascade-delete dependent data unexpectedly. + +**Why it happens:** +SQLite doesn't support true ALTER TABLE — it creates a new table, copies data, drops the old one. If foreign keys are enabled (`PRAGMA foreign_keys = ON`) during this process, cascade relationships can destroy data. The pragma can't be toggled mid-transaction, and migration tools run in transactions. + +**How to avoid:** +- Use Alembic's batch mode for SQLite schema changes +- For simple column additions, use `add_column` + `add_index` without foreign key constraints +- Enforce user_id constraints at application level for SQLite +- Consider backfilling data BEFORE adding constraints +- Test migrations on production data copy, not empty database +- Have rollback plan and backup before running migration + +**Warning signs:** +- Migration succeeds but table is empty +- "FOREIGN KEY constraint failed" errors +- Dependent tables lose records after migration +- `__old_tablename` tables remain in schema + +**Phase to address:** +Phase 1 (Database Schema & User Model) — Design migration strategy carefully, test thoroughly with realistic data volumes. + +--- + +### Pitfall 3: Existing JWT Tokens Become Invalid + +**What goes wrong:** +After deploying user account changes, the single shared password JWT tokens become invalid. All active users are logged out. Worse, the JWT payload structure changes (adding user_id, roles) but the backend doesn't handle old token format, causing 401s or crashes. + +**Why it happens:** +Current JWTs encode the shared password authentication, not a user identity. After migration, the auth system expects `user_id` in the token payload. Old tokens lack this field. Token verification fails or returns None for current_user. + +**How to avoid:** +- Plan for token format versioning (include `version: 2` in new tokens) +- Handle both old and new token formats during transition period +- Accept that a forced re-login is sometimes necessary — communicate this +- Use a migration window: deploy new auth, give users 24 hours to re-login before breaking old tokens +- Consider token migration endpoint: exchange old token for new token + +**Warning signs:** +- All users logged out immediately after deployment +- "Invalid token" errors spike in logs +- `current_user.id` is None/undefined in dependency injection +- Frontend can't decode JWT payload + +**Phase to address:** +Phase 2 (Authentication & Authorization) — Handle gracefully with versioned tokens or planned re-login window. Document in migration notes. + +--- + +### Pitfall 4: Orphaned Data Without Clear Ownership + +**What goes wrong:** +Existing projects, requirements, and layouts in the database have no `owner_id`. After adding user accounts, who owns this data? Assigning it all to the first admin is wrong. Leaving it NULL breaks foreign key constraints. Making it globally accessible defeats the purpose of multi-tenancy. + +**Why it happens:** +The migration script adds `user_id` columns but doesn't define a strategy for pre-existing data. It's a business logic decision (who should own legacy data?) disguised as a technical problem. + +**How to avoid:** +- Decide ownership strategy BEFORE migration: + - Option A: Assign all to designated "admin" user (create during migration) + - Option B: Create "shared" workspace owned by system user + - Option C: Mark legacy data as owned by each user (copy to all users) + - Option D: Delete legacy data (if acceptable) +- Document the decision and rationale +- Make it reversible if possible (backup tables) +- Allow users to claim/import legacy data post-migration + +**Warning signs:** +- NULL user_id values in production +- Foreign key constraint errors after migration +- Users complain about missing historical data +- Unclear who can edit legacy projects + +**Phase to address:** +Phase 1 (Database Schema & User Model) — Define ownership strategy as part of migration plan, not an afterthought. + +--- + +### Pitfall 5: Role Explosion and Privilege Creep + +**What goes wrong:** +Starting with simple roles (viewer, editor, admin) quickly becomes dozens of overly-specific roles: "ProjectOwnerCanEditGraphsButNotDelete", "EditorForPillarRequirementsOnly". Permission checking logic becomes unmaintainable. Users accumulate permissions as they're granted roles for different projects. + +**Why it happens:** +Creating new roles is easy. Removing or merging roles is hard. Each edge case ("what if user needs read on Project A but write on Project B?") tempts adding a new role instead of using proper RBAC patterns. Users change roles but old permissions aren't revoked. + +**How to avoid:** +- Start with MINIMAL roles: Owner, Editor, Viewer +- Roles are per-project, not global +- Don't create roles for every permission combination +- Implement permission inheritance: Editor includes Viewer permissions +- Use project-level assignments, not feature-level roles +- Audit user roles quarterly, revoke unused permissions +- Track role changes in audit log + +**Warning signs:** +- More roles than users +- Role names longer than 3 words +- Permission checks require nested if/else trees +- Users have 5+ roles assigned +- "I don't know what role to give this user" + +**Phase to address:** +Phase 3 (Project Ownership & Sharing) — Define minimal, composable role set from the start. Resist adding roles. + +--- + +### Pitfall 6: Invite System Security Weaknesses + +**What goes wrong:** +Invite codes are predictable (sequential IDs, simple UUIDs), reusable after first use, never expire, or lack the invite token in the registration endpoint. Attackers enumerate invite codes or reuse leaked ones to create unauthorized accounts. + +**Why it happens:** +Invite system feels like a simple feature: generate token, send email, validate token on signup. The security edge cases (expiration, single-use, brute force protection) get deprioritized as "nice to have." + +**How to avoid:** +- Use cryptographically secure random tokens (secrets.token_urlsafe(32)) +- Make invites single-use: mark as consumed on successful registration +- Set expiration (7-30 days) and enforce it +- Rate-limit invite generation and redemption endpoints +- Don't expose invite codes in URLs if possible (use POST body) +- Log invite creation/redemption for audit trail +- Allow invite revocation before use + +**Warning signs:** +- Invite codes are short (< 20 chars) or sequential +- Same invite works multiple times +- Invites never expire +- No rate limiting on /register endpoint +- Invite validation happens client-side only + +**Phase to address:** +Phase 2 (Authentication & Authorization) — Build invite system with security as primary concern, not afterthought. + +--- + +### Pitfall 7: Frontend State Management Assumes Single User + +**What goes wrong:** +Angular services cache user context globally, localStorage keys don't include user_id, or the app doesn't clear state on logout. User A logs out, User B logs in on the same browser, and sees User A's cached project data. + +**Why it happens:** +Single-user app stored state globally (localStorage, service singletons) with no concept of user identity. After adding multi-user, the frontend team updates login/logout but forgets cached state. + +**How to avoid:** +- Clear localStorage/sessionStorage on logout +- Namespace storage keys by user_id: `user_${userId}_graph_state` +- Use request-scoped state, not global singletons where possible +- Angular Guards should verify token validity on route change +- Test user switching: logout → login as different user → verify no leaked state +- Consider using Angular signals for reactive user-scoped state + +**Warning signs:** +- User sees previous user's data briefly after login +- Editing as User B modifies User A's project +- "My dashboard shows someone else's stats" +- Graph layouts persist across user accounts + +**Phase to address:** +Phase 2 (Authentication & Authorization) — Update frontend state management to be user-aware. + +--- + +### Pitfall 8: Cascading Deletes Destroy Shared Data + +**What goes wrong:** +User A shares a project with User B. User A deletes their account. CASCADE DELETE removes the project, wiping out User B's work and edit history. Or worse, the delete fails with foreign key violations because of shared data. + +**Why it happens:** +Foreign key CASCADE DELETE seems convenient for cleanup. But in multi-tenant systems with sharing, data ownership is not 1:1. A project might have an owner but also collaborators. Deleting the owner shouldn't delete the project. + +**How to avoid:** +- Transfer ownership instead of deleting when user has owned projects +- Use soft deletes (deleted_at timestamp) instead of CASCADE DELETE +- Require explicit "delete all my data" confirmation with warnings +- Archive user data instead of deleting (GDPR compliance) +- For shared projects: transfer to co-owner or mark as archived +- Test deletion scenarios: user with projects, user as collaborator, user as viewer + +**Warning signs:** +- User deletion is instant with no warnings +- "I lost my project when the owner left the team" +- Foreign key constraint errors on user deletion +- No way to recover deleted data + +**Phase to address:** +Phase 3 (Project Ownership & Sharing) — Design deletion/archival strategy that respects data relationships. + +--- + +### Pitfall 9: Missing Edit Attribution and Audit Trail + +**What goes wrong:** +Edit history shows changes but not WHO made them. In a collaborative environment, users can't see "User B edited my requirement" or "Who broke the graph?" The blame/credit information is lost. + +**Why it happens:** +Single-user app had no concept of attribution — every edit was "me." The existing `requirement_history` table might have timestamps but no `edited_by_user_id` field. + +**How to avoid:** +- Add `created_by_user_id` and `modified_by_user_id` to all tables with user-generated content +- Update history/audit tables to capture user identity +- Display "Last edited by [username] at [time]" in UI +- Allow filtering history by user +- Consider including user's display name in history JSON (denormalized) to survive user deletions + +**Warning signs:** +- Edit history shows timestamps but no usernames +- "Who made this change?" has no answer +- Audit log exists but lacks user attribution +- Collaboration features planned but no user tracking + +**Phase to address:** +Phase 1 (Database Schema & User Model) — Add attribution columns from the start. + +--- + +### Pitfall 10: Dependency Injection Fails to Scope Tenant Context + +**What goes wrong:** +FastAPI dependency that extracts `current_user` is request-scoped, but a service or background task uses a module-level database session. The tenant filter isn't applied, causing data leaks. Or, background tasks receive user objects with secrets instead of just user_id. + +**Why it happens:** +Mixing dependency lifespans: per-request (auth), application-lifespan (DB connection pool), and background tasks (out-of-request context). The tenant_id from the JWT is only available in request scope, but business logic runs elsewhere. + +**How to avoid:** +- Use per-request DB sessions, not global singletons +- FastAPI dependencies: extract user, pass user_id to service layer +- Background tasks: pass IDs, not objects; rehydrate via dependencies +- Never store user context in global variables +- Service layer methods take user_id as explicit parameter +- Test with multiple concurrent users to catch scope leaks + +**Warning signs:** +- Background task sends email to wrong user +- "Current user is None" errors in async tasks +- Data visible to wrong user under concurrent load +- DB session reused across requests + +**Phase to address:** +Phase 2 (Authentication & Authorization) — Establish dependency injection patterns that properly scope user context. + +--- + +## Technical Debt Patterns + +Shortcuts that seem reasonable but create long-term problems. + +| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | +|----------|-------------------|----------------|-----------------| +| Skip invite system, use direct registration | Faster MVP | No growth control, spam signups, unclear user source | Never for v2 — stated requirement | +| Check permissions in frontend only | Less backend code | Trivial to bypass, security theater | Never — always verify backend | +| Use global admin toggle instead of RBAC | Simple boolean check | Can't do granular sharing, rewrite needed for collaboration | Never — RBAC is v2 requirement | +| Store user_id in localStorage instead of JWT | Simpler token structure | Easily spoofed, XSS vulnerability, defeats authentication | Never — security risk | +| Soft-delete users but not their projects | Cleaner user table | Orphaned projects, unclear ownership | Acceptable if projects transferred first | +| SQLite without foreign keys | Easier migration | No referential integrity, manual cleanup, data inconsistency | Acceptable for MVP, fix in v2 | +| Add `user_id` as nullable column | Non-breaking migration | Half the codebase forgets to filter by it | Only during transition, make NOT NULL ASAP | +| Embed role in JWT instead of database | Fewer DB queries | Role changes require re-login, harder to audit | Acceptable if roles rarely change | + +## Integration Gotchas + +Common mistakes when connecting to external services. + +| Integration | Common Mistake | Correct Approach | +|-------------|----------------|------------------| +| Gitea issue integration | Include user's Gitea token in JWT | Store Gitea token per-user in database, encrypt at rest | +| Email (invite system) | Send from user's email address | Use system email (noreply@), track sent_by_user_id separately | +| Frontend SPA | Auth state in component, not service | Centralized auth service with RxJS for reactive state | +| Background tasks | Pass entire user object to task | Pass user_id only, re-fetch in task context | + +## Performance Traps + +Patterns that work at small scale but fail as usage grows. + +| Trap | Symptoms | Prevention | When It Breaks | +|------|----------|------------|----------------| +| N+1 queries loading user for each project | 100s of queries for dashboard | Eager load user with project: `.options(joinedload(Project.owner))` | >50 projects per view | +| Check permissions on every graph node render | Slow graph rendering | Bulk permission check on page load, cache results | >100 nodes | +| No pagination on shared projects list | Slow API calls | Paginate, default limit 50 | User in >20 projects | +| Recalculate full audit history on every edit | Edit lag increases | Incremental history, paginate old history | >1000 history entries | +| JWT validation queries DB for user on every request | DB bottleneck | Cache user in request context after first validation | >10 requests/second | + +## Security Mistakes + +Domain-specific security issues beyond general web security. + +| Mistake | Risk | Prevention | +|---------|------|------------| +| Include user_id in URL path (/projects/user/123/items) | Enumeration attack, user ID leak | Use /projects/me or /projects (filter by JWT user) | +| Expose user emails in project collaborator API | Privacy leak, phishing target list | Return user IDs + display names only | +| Allow project transfer without new owner consent | Unwanted ownership, storage quota bypass | Require new owner to accept transfer | +| Reuse password reset tokens as invite tokens | Token confusion, security model mismatch | Separate token types with different validation | +| No rate limit on invite endpoint | Spam, resource exhaustion | Rate limit: 10 invites/hour per user | +| Allow changing your own role on a project | Privilege escalation | Only project owner can change roles, can't change own | +| DELETE /users/:id accepts any user_id | Account takeover, data destruction | Only allow /users/me, verify JWT user matches | + +## UX Pitfalls + +Common user experience mistakes in this domain. + +| Pitfall | User Impact | Better Approach | +|---------|-------------|-----------------| +| No indication of project owner vs. collaborator | Confusion about edit permissions | Badge/icon showing role, owner prominently displayed | +| Can't tell who made an edit | Blame culture, lost context | "Edited by [name] [time ago]" on every change | +| Force logout on every deployment | Frustration, work loss | 24-hour token expiry, graceful re-auth | +| Invite links expire too quickly (1 day) | Missed invites, support burden | 7-day default, 30-day option | +| No way to leave a shared project | Cluttered project list | "Leave project" button for non-owners | +| Shared projects look same as owned projects | Can't find my projects | Visual distinction, filter by role | +| Delete account is one-click | Accidental deletion, data loss | Require typing account name to confirm | + +## "Looks Done But Isn't" Checklist + +Things that appear complete but are missing critical pieces. + +- [ ] **User registration:** Often missing email verification — verify emails are unique, consider confirmation +- [ ] **Invite system:** Often missing invite revocation — verify admin can cancel pending invites +- [ ] **RBAC implementation:** Often missing inheritance (editor includes viewer) — verify role permissions compose correctly +- [ ] **Multi-user queries:** Often missing tenant filter on JOIN tables — verify cross_pillar_links filtered by project owner +- [ ] **Logout:** Often missing token revocation/blacklist — decide if tokens are valid until expiry or revocable +- [ ] **Project sharing:** Often missing "can they still edit after shared?" test — verify permissions persist correctly +- [ ] **User deletion:** Often missing "what happens to their projects?" flow — verify ownership transfer or archival +- [ ] **Edit history:** Often missing user attribution on old edits — verify migration backfills created_by/modified_by +- [ ] **Role changes:** Often missing "does it take effect immediately?" — verify role updates don't require re-login +- [ ] **Password reset:** Often missing rate limiting — verify can't spam reset emails +- [ ] **Frontend state:** Often missing user-scoped caching — verify localStorage cleared on logout +- [ ] **Audit log:** Often missing sensitive action logging (role changes, deletions) — verify admin actions tracked + +## Recovery Strategies + +When pitfalls occur despite prevention, how to recover. + +| Pitfall | Recovery Cost | Recovery Steps | +|---------|---------------|----------------| +| Deployed without tenant filters | HIGH | Emergency rollback, add filters, test with multiple users, redeploy | +| Migration destroyed data | HIGH | Restore from backup, fix migration script, re-run with copy of production data | +| Role explosion (30+ roles) | MEDIUM | Audit actual role usage, merge similar roles, migrate users to simplified roles | +| Invite codes leaked/enumerated | MEDIUM | Revoke all pending invites, generate new codes, notify legitimate users | +| JWT tokens invalidated prematurely | LOW | Communicate forced re-login, monitor for issues, extend token expiry next time | +| User deleted with active projects | MEDIUM | Soft-delete user instead, transfer projects to team lead, restore from backup if recent | +| Frontend leaks previous user data | LOW | Force cache clear (version localStorage key), deploy fix, notify affected users | +| Cascading delete destroyed shared data | HIGH | Restore from backup, change CASCADE to RESTRICT, add ownership transfer logic | + +## Pitfall-to-Phase Mapping + +How roadmap phases should address these pitfalls. + +| Pitfall | Prevention Phase | Verification | +|---------|------------------|--------------| +| Forgetting tenant_id filters | Phase 1 (Database Schema) | Integration test: User A can't see User B's data | +| SQLite migration failures | Phase 1 (Database Schema) | Test migration on production data copy, verify no data loss | +| Invalid JWT tokens | Phase 2 (Auth & Authorization) | Test old token format handled gracefully | +| Orphaned data without owner | Phase 1 (Database Schema) | Verify all rows have non-null user_id after migration | +| Role explosion | Phase 3 (Project Ownership) | Verify <= 5 roles exist, permission matrix simple | +| Invite system security | Phase 2 (Auth & Authorization) | Security audit: tokens secure, single-use, expire | +| Frontend state leaks | Phase 2 (Auth & Authorization) | Test user switching, verify state cleared | +| Cascading deletes | Phase 3 (Project Ownership) | Test user deletion with owned/shared projects | +| Missing edit attribution | Phase 1 (Database Schema) | Verify edited_by_user_id on all history entries | +| Tenant context leaks | Phase 2 (Auth & Authorization) | Load test with concurrent users, verify isolation | + +## Sources + +**Multi-Tenancy Patterns:** +- [MergeBoard - Multitenancy with FastAPI, SQLAlchemy and PostgreSQL](https://mergeboard.com/blog/6-multitenancy-fastapi-sqlalchemy-postgresql/) +- [Multi-Tenant Architecture with FastAPI: Design Patterns and Pitfalls](https://medium.com/@koushiksathish3/multi-tenant-architecture-with-fastapi-design-patterns-and-pitfalls-aa3f9e75bf8c) +- [Python FastAPI Postgres SqlAlchemy Row Level Security Multitenancy](https://adityamattos.com/multi-tenancy-in-python-fastapi-and-sqlalchemy-using-postgres-row-level-security) + +**SQLite Limitations:** +- [The SQLite Renaissance: Why the World's Most Deployed Database Is Taking Over Production in 2026](https://dev.to/pockit_tools/the-sqlite-renaissance-why-the-worlds-most-deployed-database-is-taking-over-production-in-2026-3jcc) +- [A mere add_foreign_key can wipe out your whole Rails+SQLite production table](https://kyrylo.org/software/2025/09/27/a-mere-add-foreign-key-can-wipe-out-your-whole-rails-sqlite-production-table.html) +- [Fixing ALTER TABLE errors with Flask-Migrate and SQLite](https://blog.miguelgrinberg.com/post/fixing-alter-table-errors-with-flask-migrate-and-sqlite) + +**JWT Security:** +- [JWT vs PASETO vs Branca — The Future of Secure Tokens in 2026](https://mojoauth.com/blog/jwt-vs-paseto-vs-branca-the-future-of-secure-tokens-in-2026) +- [Beyond the Secret: The Silent Risks of JWT and Machine Identity](https://medium.com/@instatunnel/beyond-the-secret-the-silent-risks-of-jwt-and-machine-identity-49bea4aa4547) +- [JWT Security Best Practices:Checklist for APIs](https://curity.io/resources/learn/jwt-best-practices/) + +**RBAC Implementation:** +- [6 Common Role Based Access Control (RBAC) Implementation Pitfalls](https://idenhaus.com/rbac-implementation-pitfalls/) +- [Role-Based Access Control Best Practices for 2026](https://www.techprescient.com/blogs/role-based-access-control-best-practices/) +- [Common Challenges in Role-Based Access Control Implementation](https://censinet.com/perspectives/common-challenges-role-based-access-control-implementation) + +**Invite System Security:** +- [How can I enhance the privacy and security of my invite-only event?](https://help.rsvpify.com/en/articles/5517672-how-can-i-enhance-the-privacy-and-security-of-my-invite-only-event) + +**Data Migration:** +- [Top data migration mistakes to avoid in 2026](https://www.kellton.com/kellton-tech-blog/top-data-migration-mistakes-to-avoid) +- [Top 10 Data Migration Risks and How to Avoid Them in 2026](https://medium.com/@kanerika/top-10-data-migration-risks-and-how-to-avoid-them-in-2026-fb5dc93c12f5) + +**FastAPI Dependency Injection:** +- [Dependency Injection in FastAPI: 2026 Playbook for Modular, Testable APIs](https://thelinuxcode.com/dependency-injection-in-fastapi-2026-playbook-for-modular-testable-apis/) +- [How to Use Dependency Injection in FastAPI](https://oneuptime.com/blog/post/2026-02-02-fastapi-dependency-injection/view) + +**Cascading Deletes:** +- [Why on_delete=models.CASCADE Doesn't Work as Expected in Django's Multi-Tenant Architecture](https://medium.com/@kevinrawal/why-on-delete-models-cascade-doesnt-work-as-expected-in-django-s-multi-tenant-architecture-45aac3faad3d) +- [Foreign Keys vs Performance (Part 3): The CASCADE DELETE Story](https://medium.com/@thyagodoliveiraperez/foreign-keys-vs-performance-part-3-the-cascade-delete-story-aac5cabd843b) + +**Permission Inheritance:** +- [SharePoint: Breaking and Managing Permission Inheritance](https://td.usnh.edu/TDClient/60/Portal/KB/ArticleDet?ID=3173) +- [Top 5 Common SharePoint Permissions Mistakes and How to Fix Them](https://lightningtools.com/blog/top-5-common-sharepoint-permissions-mistakes-and-how-to-fix-them/) + +**Angular State Management:** +- [Angular State Management for 2025](https://nx.dev/blog/angular-state-management-2025) +- [7 Angular 2026 Predictions That Could Redefine Frontend Architecture](https://dev.to/karol_modelski/7-angular-2026-predictions-that-could-redefine-frontend-architecture-e5n) + +--- +*Pitfalls research for: req-planner v2.0 multi-tenancy migration* +*Researched: 2026-03-18* diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md new file mode 100644 index 0000000..f914a82 --- /dev/null +++ b/.planning/research/STACK.md @@ -0,0 +1,302 @@ +# Stack Research: Multi-Tenancy & User Accounts + +**Domain:** Requirements management tool with multi-user support +**Researched:** 2026-03-18 +**Confidence:** HIGH + +## Executive Summary + +Adding user accounts, invite-only registration, RBAC, and tenant isolation to your existing FastAPI + SQLAlchemy + SQLite stack requires minimal new dependencies. The core stack remains unchanged - only authentication primitives need upgrading from shared password to individual accounts. + +**Key additions:** +- **pwdlib** with Argon2 for password hashing (replaces bcrypt/passlib) +- **email-validator** for email validation in Pydantic models +- **secrets** module (stdlib) for invite token generation +- **Tenant isolation** via SQLAlchemy query filters (no library needed) + +**No changes needed:** Your existing FastAPI, SQLAlchemy 2.0, SQLite, PyJWT, and Angular 17 stack remain unchanged. + +## Recommended Stack Additions + +### Authentication & Security + +| Technology | Version | Purpose | Why Recommended | +|------------|---------|---------|-----------------| +| pwdlib | 0.4.0+ | Password hashing with Argon2 | Modern replacement for passlib (deprecated in Python 3.13). FastAPI official docs now recommend pwdlib. Argon2 is GPU-resistant and OWASP-recommended for 2026. | +| email-validator | 2.2.0+ | Email validation for user registration | Required by Pydantic's EmailStr type. Standard for email validation with deliverability checks. | +| secrets | stdlib | Secure invite token generation | Python standard library (3.6+). Cryptographically secure token generation with 256-bit randomness. Use `secrets.token_urlsafe(32)` for invite links. | + +### NO Library Needed (Use Patterns) + +| Capability | Implementation | Why No Library | +|------------|----------------|----------------| +| Multi-tenancy | SQLAlchemy query filters with `user_id` FK | Simple pattern - add `user_id` column to all tables, filter queries by current user. PostgreSQL Row-Level Security (RLS) not available in SQLite. | +| RBAC | Enum + dependency injection | Three roles (viewer/editor/owner) fit in simple Enum. Use FastAPI Depends() to check role on routes. Libraries like fastapi-user-auth add unnecessary complexity for 3 roles. | +| Edit attribution | Add `modified_by_user_id` column | You already track edit history - just add user reference to existing `requirement_history` table. | +| Email sending | Python `smtplib` (stdlib) | Built-in library sufficient for invite emails. Use FastAPI BackgroundTasks for async sending. No need for Celery yet. | + +## Unchanged Core Stack + +Your existing stack is already optimal for this use case: + +| Technology | Version | Status | Notes | +|------------|---------|--------|-------| +| FastAPI | Current | ✓ Keep | Dependency injection perfect for RBAC | +| SQLAlchemy | 2.0+ | ✓ Keep | Event listeners support tenant filtering | +| SQLite | 3.x | ✓ Keep | WAL mode supports concurrent readers + 1 writer (sufficient for small team) | +| PyJWT | 2.x | ✓ Keep | Already used for JWT - extend payload to include user_id and role | +| Angular | 17 | ✓ Keep | Frontend unchanged - JWT payload has new fields | +| Pydantic | 2.x | ✓ Keep | Add email-validator extra for EmailStr | + +## Installation + +```bash +# Backend additions only +pip install "pwdlib[argon2]>=0.4.0" +pip install "pydantic[email]>=2.0" + +# No changes to frontend +``` + +## Integration Patterns + +### 1. Password Hashing (pwdlib + Argon2) + +```python +from pwdlib import PasswordHash + +# Initialize once at startup +password_hash = PasswordHash.recommended() + +# Hash password on registration +hashed = password_hash.hash("user_password") + +# Verify on login +is_valid = password_hash.verify("user_password", hashed) +``` + +**Why this pattern:** FastAPI docs migrated from passlib to pwdlib in 2026. Argon2 is more secure than bcrypt and future-proof. + +### 2. Email Validation (Pydantic EmailStr) + +```python +from pydantic import BaseModel, EmailStr + +class UserCreate(BaseModel): + email: EmailStr # Automatically validated + password: str +``` + +**Why this pattern:** Pydantic's EmailStr uses email-validator under the hood. No manual validation needed. + +### 3. Invite Token Generation (secrets) + +```python +import secrets +from datetime import datetime, timedelta + +# Generate invite token +invite_token = secrets.token_urlsafe(32) # 256 bits +expires_at = datetime.utcnow() + timedelta(hours=24) + +# Store in database +invite = Invite(token=invite_token, email=email, expires_at=expires_at) +``` + +**Why this pattern:** Standard library, cryptographically secure, URL-safe for email links. + +### 4. Multi-Tenancy (SQLAlchemy Filters) + +```python +# Add to all models +class RequirementNode(Base): + __tablename__ = "requirement_nodes" + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + # ... other columns + +# Filter queries by current user +def get_requirements(db: Session, current_user: User): + return db.query(RequirementNode).filter( + RequirementNode.user_id == current_user.id + ).all() +``` + +**Why this pattern:** SQLite doesn't support PostgreSQL's Row-Level Security. Manual filtering is explicit and debuggable. Add SQLAlchemy event listener for automatic filtering if needed later. + +### 5. RBAC (Enum + Dependency Injection) + +```python +from enum import Enum +from fastapi import Depends, HTTPException + +class Role(str, Enum): + VIEWER = "viewer" + EDITOR = "editor" + OWNER = "owner" + +def require_role(required: Role): + def check_role(current_user: User = Depends(get_current_user)): + if current_user.role not in [required, Role.OWNER]: # Owner has all permissions + raise HTTPException(status_code=403, detail="Insufficient permissions") + return current_user + return check_role + +# Use in routes +@app.delete("/requirements/{id}") +async def delete_requirement( + id: int, + current_user: User = Depends(require_role(Role.EDITOR)) +): + ... +``` + +**Why this pattern:** FastAPI's Depends() makes role checks declarative. No library needed for 3 roles. + +### 6. JWT Payload Extension + +```python +# Extend existing JWT to include user info +def create_access_token(user: User) -> str: + payload = { + "sub": str(user.id), + "email": user.email, + "role": user.role.value, + "exp": datetime.utcnow() + timedelta(hours=24) + } + return jwt.encode(payload, SECRET_KEY, algorithm="HS256") +``` + +**Why this pattern:** Minimal change to existing JWT implementation. Frontend receives role for UI decisions. + +## What NOT to Add + +| Avoid | Why | Use Instead | +|-------|-----|-------------| +| FastAPI-Users | Over-engineered for your use case. Brings OAuth, 2FA, cookie auth you don't need. | Custom auth with pwdlib + PyJWT (you already have JWT logic) | +| Alembic | SQLite migrations are low-risk. You're self-hosted single-user currently. | Direct SQL migration scripts for v2 upgrade | +| Celery / Redis | Overkill for invite emails. Adds deployment complexity. | FastAPI BackgroundTasks + smtplib | +| SQLAlchemy-History | You already have `requirement_history` table with snapshots. | Add `modified_by_user_id` FK to existing table | +| PostgreSQL RLS libraries | SQLite doesn't support Row-Level Security. | Manual SQLAlchemy query filters | +| Casbin (RBAC library) | Policy engine for 100+ permission combinations. You have 3 roles. | Python Enum + FastAPI Depends() | + +## Database Schema Changes + +### New Tables + +```sql +-- Users table +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + hashed_password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Invite tokens table +CREATE TABLE invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + created_by INTEGER REFERENCES users(id) +); +``` + +### Modified Tables + +Add `user_id` FK to existing tables: +- `requirement_nodes` - owner of requirement +- `cross_pillar_links` - who created link +- `projects` - project owner +- `requirement_history` - who made the change (`modified_by_user_id`) +- `graph_layouts` - who saved layout + +**Migration strategy:** Existing data gets assigned to "system" user (id=1) created during migration. + +## SQLite Concurrency Considerations + +### WAL Mode (Already Recommended) + +```python +# Ensure WAL mode is enabled +engine = create_engine( + "sqlite:///data/planner.db", + connect_args={"check_same_thread": False}, + echo=False +) +with engine.connect() as conn: + conn.execute(text("PRAGMA journal_mode=WAL")) +``` + +**Capabilities:** +- Multiple concurrent readers (unlimited) +- One writer at a time (sufficient for small team collaboration) +- Writers don't block readers + +**Limitations:** +- No true concurrent writes (BEGIN CONCURRENT is experimental, not production-ready) +- Single writer serialization means write-heavy workloads may see SQLITE_BUSY errors + +**When to migrate to PostgreSQL:** If you exceed ~10 concurrent users with frequent writes (requirements updates). For now, SQLite + WAL handles your use case. + +## Version Compatibility Matrix + +| Package | Version | Compatible With | Notes | +|---------|---------|-----------------|-------| +| pwdlib | 0.4.0+ | Python 3.9+ | Requires `pwdlib[argon2]` extra for Argon2 support | +| email-validator | 2.2.0+ | Pydantic 2.x | Required by `pydantic[email]` extra | +| PyJWT | 2.8.0+ | FastAPI 0.100+ | Already in your stack - no changes needed | +| SQLAlchemy | 2.0.0+ | Python 3.12+ | Already in your stack - no changes needed | +| Pydantic | 2.0+ | FastAPI 0.100+ | Requires email-validator for EmailStr | + +## Migration Checklist + +- [ ] Install pwdlib and email-validator +- [ ] Create `users` and `invites` tables +- [ ] Add `user_id` FK to all existing tables +- [ ] Migrate existing data to "system" user +- [ ] Replace shared password logic with user registration/login +- [ ] Add role-based route decorators +- [ ] Update JWT payload to include user_id and role +- [ ] Add query filters for tenant isolation +- [ ] Update frontend to handle user-specific data +- [ ] Test multi-user scenarios with WAL mode + +## Security Checklist + +- [ ] Use `pwdlib.PasswordHash.recommended()` for Argon2 defaults +- [ ] Validate all user inputs with Pydantic (EmailStr, password length) +- [ ] Use `secrets.token_urlsafe(32)` for invite tokens (256-bit) +- [ ] Set invite token expiration (24 hours recommended) +- [ ] Filter ALL queries by `user_id` (tenant isolation) +- [ ] Use FastAPI Depends() for role checks on protected routes +- [ ] Set JWT expiration (24 hours recommended) +- [ ] Enable SQLite WAL mode for concurrent access +- [ ] Rate limit registration and login endpoints (future: middleware) +- [ ] Use HTTPS in production (Caddy/nginx reverse proxy) + +## Sources + +**HIGH Confidence (Official Documentation):** +- [FastAPI OAuth2 with JWT](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/) - Official security tutorial +- [pwdlib introduction](https://github.com/frankie567/pwdlib/discussions/1) - Modern password hashing +- [Python secrets module](https://docs.python.org/3/library/secrets.html) - Secure token generation +- [Pydantic email validation](https://docs.pydantic.dev/latest/install/) - EmailStr documentation +- [SQLite WAL mode](https://www.sqlite.org/wal.html) - Official SQLite concurrency documentation + +**MEDIUM Confidence (Industry Best Practices 2026):** +- [TestDriven.io FastAPI JWT Auth](https://testdriven.io/blog/fastapi-jwt-auth/) - Production patterns +- [FastAPI RBAC Tutorial](https://www.permit.io/blog/fastapi-rbac-full-implementation-tutorial) - Role-based access control +- [Tenant Isolation with SQLAlchemy](https://personal-web-9c834.web.app/blog/pg-tenant-isolation/) - Multi-tenancy patterns +- [The New Way To Generate Secure Tokens](https://blog.miguelgrinberg.com/post/the-new-way-to-generate-secure-tokens-in-python) - Token generation security +- [FastAPI Dependency Injection](https://fastapi.tiangolo.com/tutorial/security/get-current-user/) - Current user pattern + +--- +*Stack research for: req-planner v2.0 Multi-Tenancy & User Accounts* +*Researched: 2026-03-18* +*Confidence: HIGH - All recommendations verified against official docs and 2026 best practices* diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md new file mode 100644 index 0000000..499ce3f --- /dev/null +++ b/.planning/research/SUMMARY.md @@ -0,0 +1,321 @@ +# Project Research Summary + +**Project:** req-planner v2.0 - Multi-Tenancy & User Accounts +**Domain:** Requirements management tool with collaborative features +**Researched:** 2026-03-18 +**Confidence:** HIGH + +## Executive Summary + +The req-planner v2.0 upgrade adds user accounts, invite-only registration, role-based access control, and project sharing to an existing FastAPI + SQLAlchemy + SQLite stack. Research shows this is a **well-documented migration pattern** with established best practices from 2026. The core architecture requires minimal new dependencies (pwdlib, email-validator) and leverages existing JWT infrastructure. The main risk is **tenant isolation failures** — forgetting to filter queries by user context is the #1 cause of data leaks in multi-tenant systems. The recommended approach uses shared-schema multi-tenancy with explicit query filtering, three-tier RBAC (owner/editor/viewer), and refresh token rotation for security. + +**Critical Success Factor:** Build tenant-scoped query patterns from day one. Every query must filter by user ownership or project membership. SQLite's limitations (no Row-Level Security) require manual enforcement, making systematic testing essential. The good news: this migration is additive — existing single-user functionality remains intact during the transition, allowing incremental deployment. + +**Key Recommendation:** Structure the roadmap in dependency order: (1) Authentication foundation with user accounts, (2) Tenant isolation with project ownership, (3) RBAC for sharing, (4) Polish with attribution and invites. This sequence minimizes risk by validating tenant isolation before adding sharing complexity. + +## Key Findings + +### Recommended Stack + +The existing stack (FastAPI, SQLAlchemy 2.0, SQLite, PyJWT, Angular 17) is optimal for this use case. **Only two new dependencies required:** + +**Core additions:** +- **pwdlib (Argon2)** — Password hashing, replaces deprecated passlib. FastAPI official docs recommend it as of 2026. Argon2 is GPU-resistant and OWASP-approved. +- **email-validator** — Required for Pydantic EmailStr validation. Standard for registration forms. +- **secrets (stdlib)** — Cryptographically secure invite token generation using `token_urlsafe(32)` for 256-bit randomness. + +**No library needed (use patterns instead):** +- Multi-tenancy via SQLAlchemy query filters with `user_id` FK (SQLite doesn't support PostgreSQL Row-Level Security) +- RBAC via Enum + FastAPI dependency injection (three roles don't justify a library like Casbin) +- Edit attribution via `modified_by_user_id` column on existing `requirement_history` table +- Email sending via stdlib `smtplib` + FastAPI BackgroundTasks (Celery is overkill) + +**Critical decision:** Stick with SQLite until 100+ concurrent users or 10GB+ database. WAL mode supports unlimited concurrent readers + 1 writer, sufficient for small team collaboration. Migration to PostgreSQL adds operational complexity — delay until necessary. + +### Expected Features + +**Must have for v2.0 launch (table stakes):** +- Individual user accounts with email/password authentication +- Invite-only registration with admin-generated tokens (security requirement for self-hosted) +- Per-user project ownership with workspace isolation (core multi-tenancy) +- Three-tier RBAC (owner/editor/viewer) on projects +- Edit attribution in history showing who made each change +- Project sharing UI with role assignment +- Password reset flow (expected security hygiene) +- Permission enforcement on both backend and frontend + +**Should have for v2.x (competitive differentiators):** +- Activity feeds per project ("what changed since I last logged in") +- Workspace-level dashboard aggregating user's projects +- Anonymous viewer links for sharing with clients without accounts +- Granular audit trail with per-edit rollback capabilities + +**Defer to v3+ (future consideration):** +- Fine-grained permission customization beyond three tiers +- Inherited permissions on requirement hierarchy (complex to implement correctly) +- Organization/team hierarchies for enterprise customers +- OAuth/SSO providers +- Real-time collaborative editing (major architectural change) + +**Anti-features (commonly requested but problematic):** +- Real-time collaborative editing: Massive architectural lift, requires WebSockets and operational transforms +- Organization hierarchies: Dramatically increases permission complexity +- Social login (OAuth): Complicates self-hosted deployments +- Field-level permissions: Explosion of permission checks, confusing UX + +### Architecture Approach + +The recommended architecture uses **four layers:** Authentication (user identity via JWT with refresh tokens), Authorization (RBAC via junction tables), Tenant Isolation (explicit query filtering by user context), and Migration Strategy (backwards-compatible schema changes). The key pattern is **shared-schema multi-tenancy** — single database with `user_id` columns and explicit filters, rather than database-per-tenant (which performs worse on SQLite's single-writer model). + +**Major components:** + +1. **Authentication Layer** — Extends existing JWT system with user accounts, adds refresh tokens (15min access, 30-day refresh), invite token validation, password reset flow. Uses pwdlib for Argon2 hashing. + +2. **User Context Injection** — FastAPI dependency `get_current_user()` extracts user_id from JWT, verifies user active, returns immutable `UserContext(user_id, email, is_admin)`. All routes receive current_user as dependency. + +3. **Tenant-Scoped Queries** — Manual filtering pattern: `query(Project).filter((Project.owner_id == user_id) | (Project.members.any(user_id)))`. Explicit over implicit (no hidden session middleware). SQLite lacks Row-Level Security. + +4. **RBAC Layer** — `ProjectMember` junction table with role enum (owner/editor/viewer). Permission checks via `require_project_access(project_id, required_role)` dependency. Role hierarchy encoded: owner > editor > viewer. + +5. **Migration Layer** — Add nullable `user_id` columns, seed default admin user, backfill existing data to admin, make columns NOT NULL. Dual auth during transition (old password endpoint + new email/password endpoint). + +**Critical pattern:** Never trust client-provided user_id. Always extract from server-verified JWT. Never use global query filters in middleware (breaks admin operations, hard to debug). Never store refresh tokens in localStorage (XSS risk). + +### Critical Pitfalls + +1. **Forgetting tenant filters in queries** — #1 cause of data leaks. Every SQLAlchemy query needs `.filter(user_id == current_user.id)` or project membership check. Missing a single filter exposes another user's data. **Mitigation:** Create base query functions enforcing tenant scoping, use integration tests with multiple users, code review checklist: "Does this query filter by tenant?" + +2. **SQLite foreign key constraint failures during migration** — Adding `user_id` FKs to existing tables causes migration failures or silent data loss. SQLite's ALTER TABLE limitations force table recreation with CASCADE risks. **Mitigation:** Use Alembic batch mode, test migration on production data copy, have rollback plan, consider enforcing constraints at application level initially. + +3. **Existing JWT tokens become invalid** — Deploying new auth invalidates all current sessions. Token payload structure changes (adding user_id) breaks old token format. **Mitigation:** Version tokens (`version: 2`), handle both formats during transition, communicate forced re-login window, consider token migration endpoint. + +4. **Orphaned data without clear ownership** — Existing projects/requirements have no `owner_id`. Assigning all to admin is wrong, leaving NULL breaks constraints. **Mitigation:** Decide ownership strategy before migration (assign to admin, create shared workspace, copy to all users, or delete legacy data), document rationale, make reversible. + +5. **Role explosion and privilege creep** — Starting with 3 roles (viewer/editor/owner) becomes dozens of overly-specific roles. Users accumulate permissions. **Mitigation:** Resist adding roles, use project-level assignments not feature-level, implement permission inheritance, audit roles quarterly, track role changes in audit log. + +## Implications for Roadmap + +Based on research, the roadmap should follow a **dependency-ordered sequence** that validates tenant isolation before adding sharing complexity. The architecture naturally divides into four milestones that can be developed incrementally with clear validation gates. + +### Phase 1: Authentication Foundation +**Rationale:** User accounts are the prerequisite for all multi-tenancy features. Build the identity layer before touching data isolation. + +**Delivers:** Users can register with invite tokens, login with email/password, receive JWT access + refresh tokens, and maintain sessions across browser sessions. + +**Addresses (from FEATURES.md):** +- Individual user accounts with email/password authentication +- Invite-only registration (security requirement) +- Session management with refresh tokens +- Password reset flow + +**Avoids (from PITFALLS.md):** +- Pitfall #3: JWT token invalidation (handle old token format gracefully) +- Pitfall #6: Invite system security weaknesses (cryptographically secure tokens, single-use, expiration) + +**Implementation notes:** +- Create `users`, `invite_tokens`, `refresh_tokens` tables +- Implement pwdlib password hashing +- Add `/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout` endpoints +- Update `get_current_user()` dependency to extract from JWT +- Seed default admin user in migration + +**Research flag:** No additional research needed — well-documented pattern in FastAPI ecosystem. + +### Phase 2: Tenant Isolation & Project Ownership +**Rationale:** Establish data separation before adding collaboration. Validate users can only see their own data before allowing sharing. + +**Delivers:** Projects belong to specific users. Users see only projects they own. All queries filtered by user context. Existing data migrated to admin user. + +**Addresses (from FEATURES.md):** +- Per-user project ownership with workspace isolation +- Basic permission enforcement (backend + frontend) + +**Avoids (from PITFALLS.md):** +- Pitfall #1: Forgetting tenant filters (THE critical pitfall) +- Pitfall #4: Orphaned data without ownership +- Pitfall #10: Dependency injection scope leaks + +**Implementation notes:** +- Add `owner_id` column to `projects` (nullable initially) +- Add `created_by_id`, `updated_by_id` to `requirement_nodes` +- Update all queries to filter by ownership +- Migration: Assign existing projects to admin (id=1) +- Make `owner_id` NOT NULL after backfill +- Integration tests: User A cannot access User B's projects + +**Research flag:** No additional research needed — standard SQLAlchemy patterns. + +### Phase 3: RBAC & Project Sharing +**Rationale:** Now that tenant isolation is validated, enable controlled sharing with role-based permissions. + +**Delivers:** Project owners can invite collaborators with viewer/editor/owner roles. Permission checks enforce RBAC on all operations. UI shows user's role per project. + +**Addresses (from FEATURES.md):** +- Three-tier RBAC (owner/editor/viewer) +- Project sharing UI with role assignment +- Edit attribution in history (who made each change) + +**Avoids (from PITFALLS.md):** +- Pitfall #5: Role explosion (stick to 3 roles) +- Pitfall #8: Cascading deletes destroying shared data +- Pitfall #9: Missing edit attribution + +**Implementation notes:** +- Create `project_members` junction table with role enum +- Add `POST /projects/{id}/members`, `GET /projects/{id}/members`, `DELETE /projects/{id}/members/{user_id}` +- Implement `require_project_access(project_id, required_role)` dependency +- Update all requirement routes to check RBAC +- Add `user_id` to `requirement_history` for attribution +- Update UI to display editor names and role badges + +**Research flag:** RBAC patterns well-documented. May need phase-specific research for permission UI patterns if complex interactions emerge. + +### Phase 4: Polish & User Management +**Rationale:** With core multi-tenancy working, add user-facing features that improve collaboration experience. + +**Delivers:** Users can invite others to the platform. Admins can manage invites. History shows detailed edit attribution. Enhanced security with rate limiting. + +**Addresses (from FEATURES.md):** +- Enhanced invite system (user-driven invites, not just admin) +- Improved edit attribution in history UI +- User profile management +- Security hardening (rate limiting, token cleanup) + +**Avoids (from PITFALLS.md):** +- Pitfall #7: Frontend state management assumes single user +- Performance traps (N+1 queries, pagination) + +**Implementation notes:** +- Add `POST /auth/invites` (users can invite others) +- Add `GET /auth/invites` (list sent invites) +- Integrate email sending (SMTP + BackgroundTasks) +- Add rate limiting to auth endpoints (slowapi) +- Scheduled task for token cleanup +- Update history UI with user avatars and "edited by" attribution + +**Research flag:** Email integration may need focused research on SMTP providers, deliverability, and template design if building production-grade invite emails. + +### Phase Ordering Rationale + +**Why this order:** +1. **Authentication before isolation** — Can't isolate data without user identity +2. **Isolation before sharing** — Must prove single-user works before adding multi-user +3. **RBAC after ownership** — Roles are meaningless without established ownership model +4. **Polish last** — User-facing improvements after core functionality validated + +**How this avoids pitfalls:** +- Validates tenant isolation (Pitfall #1) in Phase 2 before sharing in Phase 3 +- Addresses data migration (Pitfall #4) early in Phase 2 when schema is simple +- Prevents role explosion (Pitfall #5) by defining minimal roles in Phase 3 +- Frontend state management (Pitfall #7) addressed progressively across phases + +**Dependency chain:** +- Phase 2 depends on Phase 1 (needs user_id from auth) +- Phase 3 depends on Phase 2 (needs project ownership to share) +- Phase 4 depends on Phase 3 (invites need roles to assign) + +**Incremental deployment:** +Each phase is independently deployable. Phase 1 can launch without breaking existing single-user mode. Phase 2 migrates data but doesn't force collaboration. Phase 3 adds sharing as opt-in. Phase 4 enhances without breaking. + +### Research Flags + +**Phases with standard patterns (skip `/gsd:research-phase`):** +- **Phase 1 (Authentication):** FastAPI JWT auth is well-documented, official examples exist, pwdlib docs comprehensive +- **Phase 2 (Tenant Isolation):** SQLAlchemy query filtering is standard ORM practice, multi-tenancy patterns mature +- **Phase 3 (RBAC):** Three-role model is trivial, junction table pattern universal, permission checks straightforward + +**Phases likely needing deeper research:** +- **Phase 4 (Email Integration):** If building production-grade invite emails, may need research on: + - SMTP provider selection (SendGrid vs Mailgun vs AWS SES) + - Email deliverability (SPF, DKIM, DMARC configuration) + - Template design and testing frameworks + - Invite link generation security (signed URLs, token rotation) + + However, for MVP, stdlib `smtplib` is sufficient — defer detailed research until email features become critical path. + +**Overall assessment:** This is a low-research-intensity roadmap. All core patterns are well-established. Only Phase 4 email features might warrant focused research if expanding beyond basic SMTP. + +## Confidence Assessment + +| Area | Confidence | Notes | +|------|------------|-------| +| Stack | **HIGH** | Recommendations verified against FastAPI official docs (2026), pwdlib is official replacement for passlib, SQLAlchemy 2.0 patterns stable. No experimental dependencies. | +| Features | **HIGH** | Feature categorization based on industry-standard collaboration tools (GitHub, Notion, Figma). Table stakes vs differentiators validated across multiple UX sources. Anti-features identified from documented failures. | +| Architecture | **HIGH** | Multi-tenancy patterns sourced from official guides, 2026 FastAPI tutorials, SQLAlchemy events documented. Dependency injection is core FastAPI feature. Refresh token rotation is OAuth 2.0 standard. | +| Pitfalls | **MEDIUM-HIGH** | Top pitfalls (#1 tenant filters, #2 SQLite migrations, #3 JWT invalidation) extensively documented across sources. Some pitfalls (#7 frontend state, #10 context scope) inferred from general patterns rather than domain-specific sources. | + +**Overall confidence:** **HIGH** + +The research is built on official documentation (FastAPI, SQLAlchemy, SQLite, Pydantic), authoritative 2026 guides, and multiple corroborating sources. Stack recommendations align with maintainer guidance. Architecture patterns are production-proven. Pitfalls are sourced from documented post-mortems and security analyses. + +### Gaps to Address + +**Minor gaps that need attention during implementation:** + +1. **Email deliverability details:** Research covered email *sending* (smtplib) but not email *deliverability* (SPF/DKIM/DMARC setup). If invite emails must reach inbox (not spam), Phase 4 may need focused research on email infrastructure. + - **How to handle:** Start with simple SMTP in development, use established email service (SendGrid/Mailgun) in production, defer deliverability research until user reports spam issues. + +2. **SQLite concurrency limits:** Research confirms WAL mode supports multiple readers + 1 writer, but exact concurrency threshold (when to migrate to PostgreSQL) is deployment-specific. + - **How to handle:** Monitor write contention in production (SQLITE_BUSY errors), plan PostgreSQL migration at 50+ concurrent writers or 10GB database, not earlier. + +3. **Cascading delete policy:** Research identifies the risk (Pitfall #8) but ownership transfer strategy is product decision, not technical. + - **How to handle:** Decide in Phase 3 planning: transfer to co-owner, mark as archived, or require explicit reassignment before user deletion. + +4. **Frontend state management details:** Research identifies risk (Pitfall #7) but Angular 17 specifics (Signals vs Services for user context) are implementation details. + - **How to handle:** Validate during Phase 2 frontend work, test user switching scenarios, follow Angular 17 best practices for auth guards. + +5. **Password strength requirements:** Research recommends enforcement but exact policy (length, complexity) is security policy decision. + - **How to handle:** Implement OWASP-recommended baseline (8 chars, mixed case, numbers) in Phase 1, allow customization in Phase 4 if self-hosted users request it. + +**No critical blockers identified.** All gaps are solvable during implementation without requiring additional research phases. + +## Sources + +### Primary (HIGH confidence) + +**Official Documentation:** +- [FastAPI OAuth2 with JWT](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/) — Official security tutorial for authentication patterns +- [pwdlib introduction](https://github.com/frankie567/pwdlib/discussions/1) — Modern password hashing, FastAPI's recommended replacement for passlib +- [Python secrets module](https://docs.python.org/3/library/secrets.html) — Secure token generation (stdlib) +- [Pydantic email validation](https://docs.pydantic.dev/latest/install/) — EmailStr documentation +- [SQLite WAL mode](https://www.sqlite.org/wal.html) — Official SQLite concurrency documentation + +**2026 Technical Guides:** +- [Introduction to Multi-Tenant Design with FastAPI](https://blog.greeden.me/en/2026/03/10/introduction-to-multi-tenant-design-with-fastapi-practical-patterns-for-tenant-isolation-authorization-database-strategy-and-audit-logs/) — Comprehensive 2026 multi-tenancy patterns +- [Building a Modern User Permission Management System with FastAPI, SQLAlchemy 2.0](https://dev.to/mochafreddo/building-a-modern-user-permission-management-system-with-fastapi-sqlalchemy-and-mariadb-5fp1) — RBAC implementation guide +- [JWT in FastAPI, the Secure Way (Refresh Tokens Explained)](https://medium.com/@jagan_reddy/jwt-in-fastapi-the-secure-way-refresh-tokens-explained-f7d2d17b1d17) — Jan 2026 refresh token pattern + +### Secondary (MEDIUM confidence) + +**Multi-Tenancy & Architecture:** +- [MergeBoard: Multitenancy with FastAPI, SQLAlchemy and PostgreSQL](https://mergeboard.com/blog/6-multitenancy-fastapi-sqlalchemy-postgresql/) — Production patterns +- [GitHub Discussion: How to implement Multi tenancy in FastAPI](https://github.com/fastapi/fastapi/discussions/6056) — Community approaches +- [The developer's guide to SaaS multi-tenant architecture — WorkOS](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture) — Tenant isolation strategies + +**RBAC & Authorization:** +- [FastAPI RBAC - Full Implementation Tutorial](https://www.permit.io/blog/fastapi-rbac-full-implementation-tutorial) — Permission layer patterns +- [How to Build a Role-Based Access Control Layer](https://www.osohq.com/learn/rbac-role-based-access-control) — RBAC design principles +- [Role-Based Access Control Best Practices for 2026](https://www.techprescient.com/blogs/role-based-access-control-best-practices/) — Industry best practices + +**Security & Data Isolation:** +- [Preventing Cross-Tenant Data Leakage in Multi-Tenant SaaS Systems](https://agnitestudio.com/blog/preventing-cross-tenant-leakage/) — Security patterns +- [JWT Security Best Practices: Checklist for APIs](https://curity.io/resources/learn/jwt-best-practices/) — Token security +- [6 Common Role Based Access Control (RBAC) Implementation Pitfalls](https://idenhaus.com/rbac-implementation-pitfalls/) — Documented failures + +**UX & Collaboration Patterns:** +- [Guide to sharing and permissions – Figma Learn](https://help.figma.com/hc/en-us/articles/1500007609322-Guide-to-sharing-and-permissions) — Collaboration UX patterns +- [User Onboarding Strategies in a B2B SaaS Application](https://auth0.com/blog/user-onboarding-strategies-b2b-saas/) — Invite-only registration patterns +- [Google Workspace Updates: "Show Editors" in Google Docs](https://workspaceupdates.googleblog.com/2021/05/view-more-context-on-google-docs-edits-with-show-editors.html) — Edit attribution patterns + +### Tertiary (LOW confidence, needs validation) + +**SQLite Limitations:** +- [A mere add_foreign_key can wipe out your whole Rails+SQLite production table](https://kyrylo.org/software/2025/09/27/a-mere-add-foreign-key-can-wipe-out-your-whole-rails-sqlite-production-table.html) — Migration risks (Rails-specific but conceptually applicable) +- [High Performance SQLite: Multi-tenancy](https://highperformancesqlite.com/watch/multi-tenancy) — Performance patterns (video, not peer-reviewed) + +--- + +**Research completed:** 2026-03-18 +**Ready for roadmap creation:** YES + +All four research files synthesized. Recommended phase structure validated against dependencies, pitfalls, and architecture patterns. No critical blockers identified. Roadmap can proceed with high confidence. diff --git a/backend/app/database.py b/backend/app/database.py index 5959c37..8d2c56f 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -20,3 +20,14 @@ def get_db(): yield db finally: db.close() + + +def switch_database(db_path: Path): + """Hot-swap the active database engine and session factory.""" + global engine, SessionLocal, DB_PATH + DB_PATH = db_path + engine.dispose() + engine = create_engine(f"sqlite:///{db_path}", echo=False) + SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False) + Base.metadata.create_all(bind=engine) + return engine diff --git a/backend/app/main.py b/backend/app/main.py index e071b3a..955b8f2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from .auth import router as auth_router from .database import Base, engine -from .routers import layouts, links, projects, requirements, stats +from .routers import databases, layouts, links, projects, requirements, stats Base.metadata.create_all(bind=engine) @@ -27,6 +27,7 @@ app.include_router(links.router) app.include_router(projects.router) app.include_router(stats.router) app.include_router(layouts.router) +app.include_router(databases.router) @app.get("/api/health") diff --git a/backend/app/models.py b/backend/app/models.py index 9ee58b6..dfd48a4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -58,7 +58,7 @@ class RequirementNode(Base): status: Mapped[Status] = mapped_column(Enum(Status), default=Status.draft) node_type: Mapped[NodeType] = mapped_column(Enum(NodeType), default=NodeType.requirement) parent_id: Mapped[str | None] = mapped_column( - String(64), ForeignKey("requirement_nodes.id", ondelete="CASCADE"), nullable=True + String(64), ForeignKey("requirement_nodes.id", ondelete="SET NULL"), nullable=True ) sort_order: Mapped[int] = mapped_column(Integer, default=0) project_id: Mapped[int | None] = mapped_column( @@ -99,7 +99,7 @@ class RequirementNode(Base): "RequirementNode", remote_side="RequirementNode.id", back_populates="children" ) children: Mapped[list["RequirementNode"]] = relationship( - "RequirementNode", back_populates="parent", cascade="all, delete-orphan", + "RequirementNode", back_populates="parent", order_by="RequirementNode.sort_order" ) project: Mapped["Project | None"] = relationship("Project", back_populates="requirements") diff --git a/backend/app/quality.py b/backend/app/quality.py index 7c659a6..06a8606 100644 --- a/backend/app/quality.py +++ b/backend/app/quality.py @@ -18,7 +18,7 @@ def validate_requirement(node: RequirementNode, children: list[RequirementNode]) traceable = True if node.node_type != NodeType.pillar: if not node.parent_id: - errors.append("Non-pillar node must have a parent (traceability)") + errors.append("Orphaned node — must be assigned a parent for traceability") traceable = False if not node.description: warnings.append("Missing description — hard to trace how this serves parent") diff --git a/backend/app/routers/databases.py b/backend/app/routers/databases.py new file mode 100644 index 0000000..f37fa35 --- /dev/null +++ b/backend/app/routers/databases.py @@ -0,0 +1,135 @@ +"""Database switching endpoints — list, switch, create, upload, and download.""" +import shutil +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from ..auth import verify_token +from .. import database as db_module + +router = APIRouter( + prefix="/api/databases", + tags=["databases"], + dependencies=[Depends(verify_token)], +) + + +class DatabaseInfo(BaseModel): + name: str + path: str + size_bytes: int + active: bool + + +class SwitchRequest(BaseModel): + path: str + + +class CreateRequest(BaseModel): + name: str + + +class CopyRequest(BaseModel): + name: str + + +def _db_info(path: Path, active: bool) -> DatabaseInfo: + return DatabaseInfo( + name=path.stem, + path=str(path), + size_bytes=path.stat().st_size, + active=active, + ) + + +@router.get("", response_model=list[DatabaseInfo]) +def list_databases(): + """List all .db files in the data directory.""" + current = Path(db_module.DB_PATH).resolve() + dbs = [] + for f in sorted(db_module.DATA_DIR.glob("*.db")): + dbs.append(_db_info(f, f.resolve() == current)) + return dbs + + +@router.post("/switch", response_model=DatabaseInfo) +def switch_db(req: SwitchRequest): + """Switch to a different database file. Path must be an existing .db file.""" + db_path = Path(req.path) + if not db_path.exists(): + raise HTTPException(404, f"Database not found: {req.path}") + if db_path.suffix != ".db": + raise HTTPException(400, "Path must point to a .db file") + db_module.switch_database(db_path) + return _db_info(db_path, True) + + +@router.post("/create", response_model=DatabaseInfo) +def create_db(req: CreateRequest): + """Create a new empty database and switch to it.""" + name = req.name.strip() + if not name: + raise HTTPException(400, "Name is required") + if not name.endswith(".db"): + name += ".db" + db_path = db_module.DATA_DIR / name + if db_path.exists(): + raise HTTPException(409, f"Database already exists: {name}") + db_module.switch_database(db_path) + return _db_info(db_path, True) + + +@router.post("/copy", response_model=DatabaseInfo) +def copy_current_db(req: CopyRequest): + """Copy the current active database to a new file (snapshot/backup).""" + name = req.name.strip() + if not name: + raise HTTPException(400, "Name is required") + if not name.endswith(".db"): + name += ".db" + dest = db_module.DATA_DIR / name + if dest.exists(): + raise HTTPException(409, f"Database already exists: {name}") + shutil.copy2(db_module.DB_PATH, dest) + return _db_info(dest, False) + + +@router.get("/active", response_model=DatabaseInfo) +def get_active(): + """Return info about the currently active database.""" + return _db_info(Path(db_module.DB_PATH), True) + + +@router.post("/upload", response_model=DatabaseInfo) +def upload_db(file: UploadFile = File(...), switch: bool = True): + """Upload a .db file from the client and optionally switch to it.""" + if not file.filename or not file.filename.endswith(".db"): + raise HTTPException(400, "File must be a .db file") + # Sanitize filename + safe_name = Path(file.filename).name + dest = db_module.DATA_DIR / safe_name + # If it already exists, add a suffix + counter = 1 + while dest.exists(): + dest = db_module.DATA_DIR / f"{Path(safe_name).stem}_{counter}.db" + counter += 1 + with open(dest, "wb") as f: + shutil.copyfileobj(file.file, f) + if switch: + db_module.switch_database(dest) + return _db_info(dest, switch) + + +@router.get("/download") +def download_db(): + """Download the currently active database file.""" + p = Path(db_module.DB_PATH) + if not p.exists(): + raise HTTPException(404, "Active database not found") + return FileResponse( + path=str(p), + filename=p.name, + media_type="application/octet-stream", + ) diff --git a/backend/app/routers/layouts.py b/backend/app/routers/layouts.py index b0bd6c2..eadf6dc 100644 --- a/backend/app/routers/layouts.py +++ b/backend/app/routers/layouts.py @@ -29,14 +29,52 @@ def _to_detail_response(layout: GraphLayout) -> GraphLayoutDetailResponse: ) +AUTOSAVE_NAME = "__autosave__" + + @router.get("", response_model=list[GraphLayoutResponse]) def list_layouts(project_id: int | None = None, db: Session = Depends(get_db)): - query = db.query(GraphLayout) + query = db.query(GraphLayout).filter(GraphLayout.name != AUTOSAVE_NAME) if project_id is not None: query = query.filter(GraphLayout.project_id == project_id) return [_to_response(l) for l in query.order_by(GraphLayout.updated_at.desc()).all()] +@router.get("/autosave", response_model=GraphLayoutDetailResponse) +def get_autosave(db: Session = Depends(get_db)): + layout = db.query(GraphLayout).filter(GraphLayout.name == AUTOSAVE_NAME).first() + if not layout: + raise HTTPException(404, "No autosave layout found") + return _to_detail_response(layout) + + +@router.put("/autosave", response_model=GraphLayoutDetailResponse) +def upsert_autosave(data: GraphLayoutUpdate, db: Session = Depends(get_db)): + layout = db.query(GraphLayout).filter(GraphLayout.name == AUTOSAVE_NAME).first() + update_data = data.model_dump(exclude_unset=True) + if "node_positions" in update_data: + update_data["node_positions"] = json.dumps(update_data["node_positions"]) + if "viewport" in update_data: + update_data["viewport"] = json.dumps(update_data["viewport"]) + + if layout: + for key, value in update_data.items(): + setattr(layout, key, value) + else: + layout = GraphLayout( + name=AUTOSAVE_NAME, + description="Auto-saved working state", + node_positions=update_data.get("node_positions", "{}"), + viewport=update_data.get("viewport", "{}"), + is_default=False, + ) + db.add(layout) + + db.commit() + db.refresh(layout) + return _to_detail_response(layout) + + @router.get("/{layout_id}", response_model=GraphLayoutDetailResponse) def get_layout(layout_id: int, db: Session = Depends(get_db)): layout = db.get(GraphLayout, layout_id) diff --git a/backend/app/routers/requirements.py b/backend/app/routers/requirements.py index a5c9c7d..680a55e 100644 --- a/backend/app/routers/requirements.py +++ b/backend/app/routers/requirements.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Body, Depends, HTTPException from sqlalchemy import func from sqlalchemy.orm import Session @@ -54,11 +54,21 @@ def _next_version(db: Session, req_id: str) -> int: return (max_ver or 0) + 1 -def _auto_summary(update_data: dict) -> str: - fields = [k for k in update_data if k in _SNAPSHOT_FIELDS] - if not fields: - return "Update" - return "Updated " + ", ".join(fields) +def _auto_summary(update_data: dict, node: RequirementNode) -> str: + changed = [] + for k in update_data: + if k not in _SNAPSHOT_FIELDS: + continue + old_val = getattr(node, k, None) + new_val = update_data[k] + # Normalize enums for comparison + if hasattr(old_val, "value"): + old_val = old_val.value + if old_val != new_val: + changed.append(k) + if not changed: + return "No changes" + return "Updated " + ", ".join(changed) def _to_response(node: RequirementNode, include_children: bool = False) -> RequirementResponse: @@ -150,6 +160,8 @@ def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depen raise HTTPException(404, f"Requirement {req_id} not found") update_data = data.model_dump(exclude_unset=True) + summary = _auto_summary(update_data, node) + for key, value in update_data.items(): setattr(node, key, value) @@ -158,7 +170,7 @@ def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depen node.traceable = quality.traceable version = _next_version(db, req_id) - _create_history_entry(db, node, version, _auto_summary(update_data)) + _create_history_entry(db, node, version, summary) db.commit() db.refresh(node) @@ -166,10 +178,53 @@ def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depen @router.delete("/{req_id}", status_code=204) -def delete_requirement(req_id: str, db: Session = Depends(get_db)): +def delete_requirement( + req_id: str, + db: Session = Depends(get_db), + delete_ids: list[str] = Body(default=[]), +): node = db.get(RequirementNode, req_id) if not node: raise HTTPException(404, f"Requirement {req_id} not found") + + # Recursively collect all descendants of the IDs marked for deletion + ids_to_delete: set[str] = set(delete_ids) + if ids_to_delete: + queue = list(ids_to_delete) + while queue: + nid = queue.pop() + child_node = db.get(RequirementNode, nid) + if child_node: + for c in child_node.children: + if c.id not in ids_to_delete: + ids_to_delete.add(c.id) + queue.append(c.id) + + # Orphan direct children not in the delete set + for child in list(node.children): + if child.id in ids_to_delete: + continue + child.parent_id = None + quality = validate_requirement(child, child.children) + child.traceable = quality.traceable + + # Flush so the parent_id = NULL changes are persisted before we delete the parent + db.flush() + + # Delete the marked descendants + for did in ids_to_delete: + desc = db.get(RequirementNode, did) + if desc: + # Orphan any grandchildren of a deleted node that aren't themselves being deleted + for grandchild in list(desc.children): + if grandchild.id not in ids_to_delete: + grandchild.parent_id = None + quality = validate_requirement(grandchild, grandchild.children) + grandchild.traceable = quality.traceable + db.flush() + db.delete(desc) + + db.flush() db.delete(node) db.commit() @@ -197,7 +252,7 @@ def get_children(req_id: str, db: Session = Depends(get_db)): node = db.get(RequirementNode, req_id) if not node: raise HTTPException(404, f"Requirement {req_id} not found") - return [_to_response(c) for c in node.children] + return [_to_response(c, include_children=True) for c in node.children] # ─── History Endpoints ──────────────────────────────────────────────────── diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index 3ae5a9a..35c9ef4 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -25,6 +25,7 @@ def get_stats(project_id: int | None = None, db: Session = Depends(get_db)): quality_passing = 0 leaves_with_tests = 0 total_leaves = 0 + orphaned = 0 for n in nodes: by_type[n.node_type.value] = by_type.get(n.node_type.value, 0) + 1 @@ -39,6 +40,9 @@ def get_stats(project_id: int | None = None, db: Session = Depends(get_db)): if n.test_spec: leaves_with_tests += 1 + if n.node_type != NodeType.pillar and not n.parent_id: + orphaned += 1 + coverage = (leaves_with_tests / total_leaves * 100) if total_leaves > 0 else 0.0 return StatsResponse( @@ -47,6 +51,7 @@ def get_stats(project_id: int | None = None, db: Session = Depends(get_db)): requirements=by_type.get("requirement", 0), sub_requirements=by_type.get("sub_requirement", 0), leaves=by_type.get("leaf", 0), + orphaned=orphaned, by_status=by_status, by_priority=by_priority, quality_passing=quality_passing, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index ea103de..e8639de 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -222,6 +222,7 @@ class StatsResponse(BaseModel): requirements: int sub_requirements: int leaves: int + orphaned: int by_status: dict[str, int] by_priority: dict[str, int] quality_passing: int diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c19fbb6..62b5963 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "pydantic>=2.10.0", "pyyaml>=6.0.2", "aiosqlite>=0.20.0", + "python-multipart>=0.0.18", ] [project.optional-dependencies] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..e890efa --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,812 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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 = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[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 = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[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 = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "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/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/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" }, +] + +[[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 = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "req-planner" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "alembic" }, + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20.0" }, + { name = "alembic", specifier = ">=1.14.0" }, + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0" }, + { name = "pydantic", specifier = ">=2.10.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "sqlalchemy", specifier = ">=2.0.36" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" }, + { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" }, + { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] diff --git a/data/demo.db b/data/demo.db new file mode 100644 index 0000000..c19ccd1 Binary files /dev/null and b/data/demo.db differ diff --git a/data/planner - 20263103.db b/data/planner - 20263103.db new file mode 100644 index 0000000..1f6bcc1 Binary files /dev/null and b/data/planner - 20263103.db differ diff --git a/data/planner.db b/data/planner.db new file mode 100644 index 0000000..50617ec Binary files /dev/null and b/data/planner.db differ diff --git a/data/planner_backup_2026-03-18.db b/data/planner_backup_2026-03-18.db new file mode 100644 index 0000000..e733c56 Binary files /dev/null and b/data/planner_backup_2026-03-18.db differ diff --git a/data/planner_midi-neo-pixel_archive.db b/data/planner_midi-neo-pixel_archive.db new file mode 100644 index 0000000..eb08b18 Binary files /dev/null and b/data/planner_midi-neo-pixel_archive.db differ diff --git a/data/planner_s11_complete_2026-03-17.db b/data/planner_s11_complete_2026-03-17.db new file mode 100644 index 0000000..c596821 Binary files /dev/null and b/data/planner_s11_complete_2026-03-17.db differ diff --git a/data/planner_state_mgmt_2026-03-17.db b/data/planner_state_mgmt_2026-03-17.db new file mode 100644 index 0000000..2df7609 Binary files /dev/null and b/data/planner_state_mgmt_2026-03-17.db differ diff --git a/data/planner_stories_wip_2026-03-17.db b/data/planner_stories_wip_2026-03-17.db new file mode 100644 index 0000000..bedf7ff Binary files /dev/null and b/data/planner_stories_wip_2026-03-17.db differ diff --git a/data/planner_tech_stack_2026-03-17.db b/data/planner_tech_stack_2026-03-17.db new file mode 100644 index 0000000..a219045 Binary files /dev/null and b/data/planner_tech_stack_2026-03-17.db differ diff --git a/docs/neopixel-command-calculator-plan.md b/docs/neopixel-command-calculator-plan.md new file mode 100644 index 0000000..81495bc --- /dev/null +++ b/docs/neopixel-command-calculator-plan.md @@ -0,0 +1,119 @@ +# Neopixel Command Calculator — Web Components Library + +## Overview +Engineering calculator for finding NeoPixel/addressable LED data transmission limits from single hardware sources. Ships as a CDN-ready Web Components library (Lit + Vite) that can be dropped into any JS app via `