Update project files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,8 +6,7 @@ __pycache__/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
# Database
|
# Database journals (temp files)
|
||||||
data/*.db
|
|
||||||
data/*.db-journal
|
data/*.db-journal
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
|
|||||||
25
.planning/MILESTONES.md
Normal file
25
.planning/MILESTONES.md
Normal file
@@ -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
|
||||||
84
.planning/PROJECT.md
Normal file
84
.planning/PROJECT.md
Normal file
@@ -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
|
||||||
|
|
||||||
|
<!-- Shipped in v1.0 and confirmed working -->
|
||||||
|
|
||||||
|
- ✓ 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
|
||||||
|
|
||||||
|
<!-- Current scope — v2.0 Multi-Tenancy & User Accounts -->
|
||||||
|
|
||||||
|
(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*
|
||||||
23
.planning/STATE.md
Normal file
23
.planning/STATE.md
Normal file
@@ -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
|
||||||
17
.planning/config.json
Normal file
17
.planning/config.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1305
.planning/research/ARCHITECTURE.md
Normal file
1305
.planning/research/ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load Diff
492
.planning/research/FEATURES.md
Normal file
492
.planning/research/FEATURES.md
Normal file
@@ -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
|
||||||
|
<button *ngIf="hasPermission('edit')">Edit</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**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*
|
||||||
443
.planning/research/PITFALLS.md
Normal file
443
.planning/research/PITFALLS.md
Normal file
@@ -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*
|
||||||
302
.planning/research/STACK.md
Normal file
302
.planning/research/STACK.md
Normal file
@@ -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*
|
||||||
321
.planning/research/SUMMARY.md
Normal file
321
.planning/research/SUMMARY.md
Normal file
@@ -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.
|
||||||
@@ -20,3 +20,14 @@ def get_db():
|
|||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from .auth import router as auth_router
|
from .auth import router as auth_router
|
||||||
from .database import Base, engine
|
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)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ app.include_router(links.router)
|
|||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(stats.router)
|
app.include_router(stats.router)
|
||||||
app.include_router(layouts.router)
|
app.include_router(layouts.router)
|
||||||
|
app.include_router(databases.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class RequirementNode(Base):
|
|||||||
status: Mapped[Status] = mapped_column(Enum(Status), default=Status.draft)
|
status: Mapped[Status] = mapped_column(Enum(Status), default=Status.draft)
|
||||||
node_type: Mapped[NodeType] = mapped_column(Enum(NodeType), default=NodeType.requirement)
|
node_type: Mapped[NodeType] = mapped_column(Enum(NodeType), default=NodeType.requirement)
|
||||||
parent_id: Mapped[str | None] = mapped_column(
|
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)
|
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
project_id: Mapped[int | None] = mapped_column(
|
project_id: Mapped[int | None] = mapped_column(
|
||||||
@@ -99,7 +99,7 @@ class RequirementNode(Base):
|
|||||||
"RequirementNode", remote_side="RequirementNode.id", back_populates="children"
|
"RequirementNode", remote_side="RequirementNode.id", back_populates="children"
|
||||||
)
|
)
|
||||||
children: Mapped[list["RequirementNode"]] = relationship(
|
children: Mapped[list["RequirementNode"]] = relationship(
|
||||||
"RequirementNode", back_populates="parent", cascade="all, delete-orphan",
|
"RequirementNode", back_populates="parent",
|
||||||
order_by="RequirementNode.sort_order"
|
order_by="RequirementNode.sort_order"
|
||||||
)
|
)
|
||||||
project: Mapped["Project | None"] = relationship("Project", back_populates="requirements")
|
project: Mapped["Project | None"] = relationship("Project", back_populates="requirements")
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def validate_requirement(node: RequirementNode, children: list[RequirementNode])
|
|||||||
traceable = True
|
traceable = True
|
||||||
if node.node_type != NodeType.pillar:
|
if node.node_type != NodeType.pillar:
|
||||||
if not node.parent_id:
|
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
|
traceable = False
|
||||||
if not node.description:
|
if not node.description:
|
||||||
warnings.append("Missing description — hard to trace how this serves parent")
|
warnings.append("Missing description — hard to trace how this serves parent")
|
||||||
|
|||||||
135
backend/app/routers/databases.py
Normal file
135
backend/app/routers/databases.py
Normal file
@@ -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",
|
||||||
|
)
|
||||||
@@ -29,14 +29,52 @@ def _to_detail_response(layout: GraphLayout) -> GraphLayoutDetailResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AUTOSAVE_NAME = "__autosave__"
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[GraphLayoutResponse])
|
@router.get("", response_model=list[GraphLayoutResponse])
|
||||||
def list_layouts(project_id: int | None = None, db: Session = Depends(get_db)):
|
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:
|
if project_id is not None:
|
||||||
query = query.filter(GraphLayout.project_id == project_id)
|
query = query.filter(GraphLayout.project_id == project_id)
|
||||||
return [_to_response(l) for l in query.order_by(GraphLayout.updated_at.desc()).all()]
|
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)
|
@router.get("/{layout_id}", response_model=GraphLayoutDetailResponse)
|
||||||
def get_layout(layout_id: int, db: Session = Depends(get_db)):
|
def get_layout(layout_id: int, db: Session = Depends(get_db)):
|
||||||
layout = db.get(GraphLayout, layout_id)
|
layout = db.get(GraphLayout, layout_id)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -54,11 +54,21 @@ def _next_version(db: Session, req_id: str) -> int:
|
|||||||
return (max_ver or 0) + 1
|
return (max_ver or 0) + 1
|
||||||
|
|
||||||
|
|
||||||
def _auto_summary(update_data: dict) -> str:
|
def _auto_summary(update_data: dict, node: RequirementNode) -> str:
|
||||||
fields = [k for k in update_data if k in _SNAPSHOT_FIELDS]
|
changed = []
|
||||||
if not fields:
|
for k in update_data:
|
||||||
return "Update"
|
if k not in _SNAPSHOT_FIELDS:
|
||||||
return "Updated " + ", ".join(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:
|
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")
|
raise HTTPException(404, f"Requirement {req_id} not found")
|
||||||
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
summary = _auto_summary(update_data, node)
|
||||||
|
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(node, key, value)
|
setattr(node, key, value)
|
||||||
|
|
||||||
@@ -158,7 +170,7 @@ def update_requirement(req_id: str, data: RequirementUpdate, db: Session = Depen
|
|||||||
node.traceable = quality.traceable
|
node.traceable = quality.traceable
|
||||||
|
|
||||||
version = _next_version(db, req_id)
|
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.commit()
|
||||||
db.refresh(node)
|
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)
|
@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)
|
node = db.get(RequirementNode, req_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
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.delete(node)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -197,7 +252,7 @@ def get_children(req_id: str, db: Session = Depends(get_db)):
|
|||||||
node = db.get(RequirementNode, req_id)
|
node = db.get(RequirementNode, req_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Requirement {req_id} not found")
|
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 ────────────────────────────────────────────────────
|
# ─── History Endpoints ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ def get_stats(project_id: int | None = None, db: Session = Depends(get_db)):
|
|||||||
quality_passing = 0
|
quality_passing = 0
|
||||||
leaves_with_tests = 0
|
leaves_with_tests = 0
|
||||||
total_leaves = 0
|
total_leaves = 0
|
||||||
|
orphaned = 0
|
||||||
|
|
||||||
for n in nodes:
|
for n in nodes:
|
||||||
by_type[n.node_type.value] = by_type.get(n.node_type.value, 0) + 1
|
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:
|
if n.test_spec:
|
||||||
leaves_with_tests += 1
|
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
|
coverage = (leaves_with_tests / total_leaves * 100) if total_leaves > 0 else 0.0
|
||||||
|
|
||||||
return StatsResponse(
|
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),
|
requirements=by_type.get("requirement", 0),
|
||||||
sub_requirements=by_type.get("sub_requirement", 0),
|
sub_requirements=by_type.get("sub_requirement", 0),
|
||||||
leaves=by_type.get("leaf", 0),
|
leaves=by_type.get("leaf", 0),
|
||||||
|
orphaned=orphaned,
|
||||||
by_status=by_status,
|
by_status=by_status,
|
||||||
by_priority=by_priority,
|
by_priority=by_priority,
|
||||||
quality_passing=quality_passing,
|
quality_passing=quality_passing,
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class StatsResponse(BaseModel):
|
|||||||
requirements: int
|
requirements: int
|
||||||
sub_requirements: int
|
sub_requirements: int
|
||||||
leaves: int
|
leaves: int
|
||||||
|
orphaned: int
|
||||||
by_status: dict[str, int]
|
by_status: dict[str, int]
|
||||||
by_priority: dict[str, int]
|
by_priority: dict[str, int]
|
||||||
quality_passing: int
|
quality_passing: int
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
"pydantic>=2.10.0",
|
"pydantic>=2.10.0",
|
||||||
"pyyaml>=6.0.2",
|
"pyyaml>=6.0.2",
|
||||||
"aiosqlite>=0.20.0",
|
"aiosqlite>=0.20.0",
|
||||||
|
"python-multipart>=0.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
812
backend/uv.lock
generated
Normal file
812
backend/uv.lock
generated
Normal file
@@ -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" },
|
||||||
|
]
|
||||||
BIN
data/demo.db
Normal file
BIN
data/demo.db
Normal file
Binary file not shown.
BIN
data/planner - 20263103.db
Normal file
BIN
data/planner - 20263103.db
Normal file
Binary file not shown.
BIN
data/planner.db
Normal file
BIN
data/planner.db
Normal file
Binary file not shown.
BIN
data/planner_backup_2026-03-18.db
Normal file
BIN
data/planner_backup_2026-03-18.db
Normal file
Binary file not shown.
BIN
data/planner_midi-neo-pixel_archive.db
Normal file
BIN
data/planner_midi-neo-pixel_archive.db
Normal file
Binary file not shown.
BIN
data/planner_s11_complete_2026-03-17.db
Normal file
BIN
data/planner_s11_complete_2026-03-17.db
Normal file
Binary file not shown.
BIN
data/planner_state_mgmt_2026-03-17.db
Normal file
BIN
data/planner_state_mgmt_2026-03-17.db
Normal file
Binary file not shown.
BIN
data/planner_stories_wip_2026-03-17.db
Normal file
BIN
data/planner_stories_wip_2026-03-17.db
Normal file
Binary file not shown.
BIN
data/planner_tech_stack_2026-03-17.db
Normal file
BIN
data/planner_tech_stack_2026-03-17.db
Normal file
Binary file not shown.
119
docs/neopixel-command-calculator-plan.md
Normal file
119
docs/neopixel-command-calculator-plan.md
Normal file
@@ -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 `<script>` tag.
|
||||||
|
|
||||||
|
**Repo:** https://theres-a-git-in-this-tea.andrewawesomo.net/andrewadmin/neopixel-command-calculator
|
||||||
|
**First hardware profile:** ESP32-WROOM-32D
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
```
|
||||||
|
neopixel-command-calculator/
|
||||||
|
├── index.html # Dev server demo
|
||||||
|
├── package.json / tsconfig.json / vite.config.ts / vitest.config.ts
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Entry point — exports + registers all components
|
||||||
|
│ ├── data/
|
||||||
|
│ │ ├── protocols.ts # WS2812B, SK6812, APA102, WS2811 timing specs
|
||||||
|
│ │ └── hardware-profiles.ts # ESP32-WROOM-32D (8 RMT channels, 2 SPI buses)
|
||||||
|
│ ├── engine/
|
||||||
|
│ │ ├── types.ts # TimingBreakdown, MaxLedsResult, StrandPlanResult, etc.
|
||||||
|
│ │ ├── timing.ts # Pure functions: calcDataTimeUs, calcMaxLeds, calcRefreshRate
|
||||||
|
│ │ └── strand-optimizer.ts # Multi-strand distribution across hardware channels
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── max-leds.ts # <neopixel-max-leds>
|
||||||
|
│ │ ├── refresh-explorer.ts # <neopixel-refresh-explorer>
|
||||||
|
│ │ ├── strand-planner.ts # <neopixel-strand-planner>
|
||||||
|
│ │ └── shared/
|
||||||
|
│ │ ├── timing-bar.ts # Stacked bar chart (data | reset | idle)
|
||||||
|
│ │ ├── math-breakdown.ts # Expandable "show the math" section
|
||||||
|
│ │ └── styles.ts # CSS custom properties (--npc-accent, --npc-bg, etc.)
|
||||||
|
│ └── utils/
|
||||||
|
│ └── format.ts # Number/unit formatting helpers
|
||||||
|
└── test/
|
||||||
|
└── engine/
|
||||||
|
├── timing.test.ts
|
||||||
|
└── strand-optimizer.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Three Calculator Components
|
||||||
|
|
||||||
|
### 1. `<neopixel-max-leds>` — Max LED Count Calculator
|
||||||
|
- **Inputs:** protocol, strand count, target refresh rate (Hz), hardware profile
|
||||||
|
- **Outputs:** max LEDs per strand, total LEDs, data rate utilization %, timing breakdown
|
||||||
|
- Shows the math: bit time x bits per LED x LED count vs available frame time
|
||||||
|
|
||||||
|
### 2. `<neopixel-refresh-explorer>` — Refresh Rate Explorer
|
||||||
|
- **Inputs:** LED count per strand, protocol, hardware profile
|
||||||
|
- **Outputs:** achievable refresh rates, timing budget visualization (bar chart: data | reset | idle)
|
||||||
|
- Interactive slider for LED count with real-time refresh rate updates
|
||||||
|
|
||||||
|
### 3. `<neopixel-strand-planner>` — Multi-Strand Planner
|
||||||
|
- **Inputs:** total LED count, protocol, hardware profile
|
||||||
|
- **Outputs:** optimal strand split across RMT/SPI channels, per-strand timing, aggregate refresh rate
|
||||||
|
- Shows which ESP32 pins/peripherals each strand uses
|
||||||
|
|
||||||
|
## Key Timing Math
|
||||||
|
|
||||||
|
### One-wire protocols (WS2812B, SK6812, WS2811)
|
||||||
|
```
|
||||||
|
frameBudgetUs = 1,000,000 / targetRefreshHz
|
||||||
|
maxLeds = floor((frameBudgetUs - resetTimeUs) / (bitsPerLed * bitPeriodNs / 1000))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** WS2812B at 30Hz:
|
||||||
|
- frameBudget = 33,333 us
|
||||||
|
- dataTimePerLed = 24 bits * 1250ns / 1000 = 30.0 us
|
||||||
|
- maxLeds = floor((33,333 - 50) / 30.0) = **1,109 LEDs**
|
||||||
|
|
||||||
|
### SPI protocols (APA102)
|
||||||
|
Clock-dependent, includes start/end frame overhead. At 8MHz SPI / 30Hz -> ~8,203 LEDs (raw timing).
|
||||||
|
|
||||||
|
### Multi-strand parallel output
|
||||||
|
All RMT channels transmit simultaneously. Aggregate refresh = 1M / max(frameTimeUs).
|
||||||
|
- 300 WS2812B on 3 strands -> 327Hz vs 110Hz single strand
|
||||||
|
|
||||||
|
## Protocol Data
|
||||||
|
|
||||||
|
| Protocol | Bits/LED | Bit Period (ns) | Reset (us) | Signal |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| WS2812B | 24 | 1250 | 50 | one-wire |
|
||||||
|
| SK6812 RGB | 24 | 1250 | 80 | one-wire |
|
||||||
|
| SK6812 RGBW | 32 | 1250 | 80 | one-wire |
|
||||||
|
| WS2811 800kHz | 24 | 1250 | 50 | one-wire |
|
||||||
|
| WS2811 400kHz | 24 | 2500 | 50 | one-wire |
|
||||||
|
| APA102 | 32 | varies (SPI clock) | 0 | spi |
|
||||||
|
|
||||||
|
## ESP32-WROOM-32D Hardware Profile
|
||||||
|
- 8 RMT channels (one-wire protocols, assignable to most GPIOs)
|
||||||
|
- 2 SPI buses: HSPI (GPIO 13), VSPI (GPIO 23) for APA102-style
|
||||||
|
- DMA-enabled operation assumed (ESP-IDF 4.4+)
|
||||||
|
- Default GPIO suggestions: 16, 17, 18, 19, 21, 22, 23, 25 for RMT
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
- **Framework:** Web Components via Lit 3.x
|
||||||
|
- **Build:** Vite library mode (ES + UMD bundles)
|
||||||
|
- **Language:** TypeScript
|
||||||
|
- **Testing:** Vitest for engine unit tests
|
||||||
|
- **Distribution:** CDN/script tag — single JS bundle, zero external deps for consumers
|
||||||
|
|
||||||
|
## Build Output
|
||||||
|
- `dist/neopixel-command-calculator.es.js` — ES module
|
||||||
|
- `dist/neopixel-command-calculator.umd.js` — UMD for `<script>` tag
|
||||||
|
- Lit bundled in (not a peer dep)
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
CSS custom properties with `--npc-*` prefix. Consumers override at any ancestor level:
|
||||||
|
```css
|
||||||
|
neopixel-max-leds {
|
||||||
|
--npc-accent: #ff6b6b;
|
||||||
|
--npc-bg: #0d1117;
|
||||||
|
--npc-text: #e6edf3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
1. **Foundation** — Data models, calculation engine, unit tests
|
||||||
|
2. **Shared UI** — CSS custom properties, timing-bar, math-breakdown, formatters
|
||||||
|
3. **Calculator Components** — All three components + dev server demo
|
||||||
|
4. **Build + Ship** — Vite library mode, UMD bundle test, push to Gitea
|
||||||
@@ -62,7 +62,8 @@
|
|||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-devkit/build-angular:dev-server",
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"allowedHosts": ["to-the-n8nth-degree.andrewawesomo.net"]
|
"allowedHosts": ["to-the-n8nth-degree.andrewawesomo.net", "local-req-planner.andrewawesomo.net"],
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|||||||
@@ -9,6 +9,67 @@
|
|||||||
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
|
<a routerLink="/dashboard" routerLinkActive="active">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-spacer"></div>
|
<div class="nav-spacer"></div>
|
||||||
|
<div class="db-switcher">
|
||||||
|
<button class="db-trigger" (click)="toggleMenu()" title="Switch database">
|
||||||
|
<span class="db-icon">DB</span>
|
||||||
|
<span class="db-name">{{ activeDbName }}</span>
|
||||||
|
</button>
|
||||||
|
@if (showDbMenu) {
|
||||||
|
<div class="db-menu">
|
||||||
|
<div class="db-menu-header">Databases</div>
|
||||||
|
@for (db of databases; track db.path) {
|
||||||
|
<button
|
||||||
|
class="db-item"
|
||||||
|
[class.active]="db.active"
|
||||||
|
(click)="switchDb(db)"
|
||||||
|
>
|
||||||
|
<span class="db-item-name">{{ db.name }}</span>
|
||||||
|
<span class="db-item-size">{{ formatSize(db.size_bytes) }}</span>
|
||||||
|
@if (db.active) {
|
||||||
|
<span class="db-item-badge">active</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<div class="db-menu-divider"></div>
|
||||||
|
<!-- Upload a .db file from your computer -->
|
||||||
|
<label class="db-item db-action">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".db"
|
||||||
|
hidden
|
||||||
|
(change)="onFileSelected($event)"
|
||||||
|
/>
|
||||||
|
@if (uploading) {
|
||||||
|
Uploading...
|
||||||
|
} @else {
|
||||||
|
Upload .db file...
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<!-- Download current active DB -->
|
||||||
|
<button class="db-item db-action" (click)="downloadDb()">
|
||||||
|
Download current DB
|
||||||
|
</button>
|
||||||
|
<div class="db-menu-divider"></div>
|
||||||
|
<!-- Create a new empty DB -->
|
||||||
|
@if (showNewDbInput) {
|
||||||
|
<div class="db-new-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="newDbName"
|
||||||
|
placeholder="new-database-name"
|
||||||
|
(keydown.enter)="createDb()"
|
||||||
|
(keydown.escape)="showNewDbInput = false"
|
||||||
|
/>
|
||||||
|
<button class="db-new-btn" (click)="createDb()">Create</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<button class="db-item db-action" (click)="showNewDbInput = true">
|
||||||
|
+ New database
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
|
|||||||
@@ -74,3 +74,164 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Database Switcher ─────────────────────────────────────────
|
||||||
|
.db-switcher {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-muted, var(--text-secondary));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-name {
|
||||||
|
max-width: 180px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 280px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-menu-header {
|
||||||
|
padding: 8px 12px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-item-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-item-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted, var(--text-secondary));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-item-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-new,
|
||||||
|
.db-action {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-new-form {
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 8px;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-new-btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,104 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { RequirementService } from './core/services/requirement.service';
|
||||||
|
import { DatabaseInfo } from './core/models/requirement.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, RouterLink, RouterLinkActive],
|
imports: [CommonModule, FormsModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss'
|
styleUrl: './app.component.scss'
|
||||||
})
|
})
|
||||||
export class AppComponent {}
|
export class AppComponent implements OnInit {
|
||||||
|
private svc = inject(RequirementService);
|
||||||
|
|
||||||
|
databases: DatabaseInfo[] = [];
|
||||||
|
activeDbPath = '';
|
||||||
|
showDbMenu = false;
|
||||||
|
newDbName = '';
|
||||||
|
showNewDbInput = false;
|
||||||
|
uploading = false;
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.loadDatabases();
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDatabases() {
|
||||||
|
this.svc.getDatabases().subscribe(dbs => {
|
||||||
|
this.databases = dbs;
|
||||||
|
const active = dbs.find(d => d.active);
|
||||||
|
if (active) this.activeDbPath = active.path;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu() {
|
||||||
|
this.showDbMenu = !this.showDbMenu;
|
||||||
|
if (this.showDbMenu) {
|
||||||
|
this.loadDatabases();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switchDb(db: DatabaseInfo) {
|
||||||
|
if (db.active) {
|
||||||
|
this.showDbMenu = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.svc.switchDatabase(db.path).subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (!file.name.endsWith('.db')) {
|
||||||
|
alert('Please select a .db file');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.uploading = true;
|
||||||
|
this.svc.uploadDatabase(file).subscribe({
|
||||||
|
next: () => window.location.reload(),
|
||||||
|
error: (err) => {
|
||||||
|
this.uploading = false;
|
||||||
|
alert(err.error?.detail ?? 'Failed to upload database');
|
||||||
|
input.value = '';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadDb() {
|
||||||
|
this.svc.downloadDatabase().subscribe(blob => {
|
||||||
|
const active = this.databases.find(d => d.active);
|
||||||
|
const name = active?.name ?? 'planner';
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${name}.db`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createDb() {
|
||||||
|
const name = this.newDbName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
this.svc.createDatabase(name).subscribe({
|
||||||
|
next: () => window.location.reload(),
|
||||||
|
error: (err) => alert(err.error?.detail ?? 'Failed to create database'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
formatSize(bytes: number): string {
|
||||||
|
return (bytes / 1024).toFixed(0) + ' KB';
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeDbName(): string {
|
||||||
|
const active = this.databases.find(d => d.active);
|
||||||
|
return active?.name ?? 'planner';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export interface Stats {
|
|||||||
requirements: number;
|
requirements: number;
|
||||||
sub_requirements: number;
|
sub_requirements: number;
|
||||||
leaves: number;
|
leaves: number;
|
||||||
|
orphaned: number;
|
||||||
by_status: Record<string, number>;
|
by_status: Record<string, number>;
|
||||||
by_priority: Record<string, number>;
|
by_priority: Record<string, number>;
|
||||||
quality_passing: number;
|
quality_passing: number;
|
||||||
@@ -146,6 +147,14 @@ export interface GraphLayoutCreate {
|
|||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Database Switching ──────────────────────────────────────────────
|
||||||
|
export interface DatabaseInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size_bytes: number;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const PRIORITY_COLORS: Record<Priority, string> = {
|
export const PRIORITY_COLORS: Record<Priority, string> = {
|
||||||
critical: '#da3633',
|
critical: '#da3633',
|
||||||
high: '#d29922',
|
high: '#d29922',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
GraphLayoutResponse,
|
GraphLayoutResponse,
|
||||||
GraphLayoutDetailResponse,
|
GraphLayoutDetailResponse,
|
||||||
GraphLayoutCreate,
|
GraphLayoutCreate,
|
||||||
|
DatabaseInfo,
|
||||||
} from '../models/requirement.model';
|
} from '../models/requirement.model';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -49,8 +50,10 @@ export class RequirementService {
|
|||||||
return this.http.put<RequirementNode>(`${this.baseUrl}/requirements/${id}`, data);
|
return this.http.put<RequirementNode>(`${this.baseUrl}/requirements/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteRequirement(id: string): Observable<void> {
|
deleteRequirement(id: string, deleteIds: string[] = []): Observable<void> {
|
||||||
return this.http.delete<void>(`${this.baseUrl}/requirements/${id}`);
|
return this.http.request<void>('DELETE', `${this.baseUrl}/requirements/${id}`, {
|
||||||
|
body: deleteIds,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -116,10 +119,49 @@ export class RequirementService {
|
|||||||
return this.http.delete<void>(`${this.baseUrl}/layouts/${id}`);
|
return this.http.delete<void>(`${this.baseUrl}/layouts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAutosaveLayout(): Observable<GraphLayoutDetailResponse> {
|
||||||
|
return this.http.get<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/autosave`);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAutosaveLayout(data: { node_positions?: Record<string, { x: number; y: number }>; viewport?: Record<string, any> }): Observable<GraphLayoutDetailResponse> {
|
||||||
|
return this.http.put<GraphLayoutDetailResponse>(`${this.baseUrl}/layouts/autosave`, data);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Stats ────────────────────────────────────────────────────
|
// ─── Stats ────────────────────────────────────────────────────
|
||||||
getStats(projectId?: number): Observable<Stats> {
|
getStats(projectId?: number): Observable<Stats> {
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (projectId != null) params['project_id'] = String(projectId);
|
if (projectId != null) params['project_id'] = String(projectId);
|
||||||
return this.http.get<Stats>(`${this.baseUrl}/stats`, { params });
|
return this.http.get<Stats>(`${this.baseUrl}/stats`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Databases ──────────────────────────────────────────────
|
||||||
|
getDatabases(): Observable<DatabaseInfo[]> {
|
||||||
|
return this.http.get<DatabaseInfo[]>(`${this.baseUrl}/databases`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveDatabase(): Observable<DatabaseInfo> {
|
||||||
|
return this.http.get<DatabaseInfo>(`${this.baseUrl}/databases/active`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchDatabase(path: string): Observable<DatabaseInfo> {
|
||||||
|
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/switch`, { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
createDatabase(name: string): Observable<DatabaseInfo> {
|
||||||
|
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/create`, { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDatabase(name: string): Observable<DatabaseInfo> {
|
||||||
|
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/copy`, { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDatabase(file: File): Observable<DatabaseInfo> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return this.http.post<DatabaseInfo>(`${this.baseUrl}/databases/upload`, formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadDatabase(): Observable<Blob> {
|
||||||
|
return this.http.get(`${this.baseUrl}/databases/download`, { responseType: 'blob' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
<div class="stat-value">{{ stats.coverage_percent }}%</div>
|
<div class="stat-value">{{ stats.coverage_percent }}%</div>
|
||||||
<div class="stat-label">Test Coverage</div>
|
<div class="stat-label">Test Coverage</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (stats.orphaned > 0) {
|
||||||
|
<div class="stat-card warn">
|
||||||
|
<div class="stat-value">{{ stats.orphaned }}</div>
|
||||||
|
<div class="stat-label">Orphaned</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="charts-row">
|
<div class="charts-row">
|
||||||
|
|||||||
@@ -31,6 +31,11 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: rgba(88, 166, 255, 0.05);
|
background: rgba(88, 166, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
border-color: var(--yellow, #d29922);
|
||||||
|
background: rgba(210, 153, 34, 0.08);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
|
|||||||
@@ -151,10 +151,43 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Orphan Warning + Re-parent -->
|
||||||
|
@if (!node.parent_id && node.node_type !== 'pillar') {
|
||||||
|
<div class="section orphan-banner">
|
||||||
|
<div class="orphan-label">Orphaned — assign a parent to restore traceability</div>
|
||||||
|
<app-node-picker
|
||||||
|
placeholder="Parent node ID..."
|
||||||
|
submitLabel="Assign"
|
||||||
|
[excludeIds]="[node.id]"
|
||||||
|
(picked)="onReparentPicked($event)"
|
||||||
|
/>
|
||||||
|
@if (reparentError) {
|
||||||
|
<div class="reparent-error">{{ reparentError }}</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Parent Requirement -->
|
<!-- Parent Requirement -->
|
||||||
@if (parentNode) {
|
@if (parentNode) {
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3 class="section-title">Parent</h3>
|
<h3 class="section-title">
|
||||||
|
Parent
|
||||||
|
@if (editing) {
|
||||||
|
<button class="section-action" (click)="showReparent = !showReparent">change</button>
|
||||||
|
<button class="section-action danger" (click)="unlinkParent()">unlink</button>
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
@if (editing && showReparent) {
|
||||||
|
<app-node-picker
|
||||||
|
placeholder="New parent node ID..."
|
||||||
|
submitLabel="Move"
|
||||||
|
[excludeIds]="[node.id]"
|
||||||
|
(picked)="onReparentPicked($event)"
|
||||||
|
/>
|
||||||
|
@if (reparentError) {
|
||||||
|
<div class="reparent-error">{{ reparentError }}</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
<div class="child-card clickable" (click)="goToNode(parentNode.id)">
|
<div class="child-card clickable" (click)="goToNode(parentNode.id)">
|
||||||
<div class="child-header">
|
<div class="child-header">
|
||||||
<span class="child-id">{{ parentNode.id }}</span>
|
<span class="child-id">{{ parentNode.id }}</span>
|
||||||
@@ -253,38 +286,16 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (showAddLink) {
|
@if (showAddLink) {
|
||||||
<div class="add-link-form">
|
<div class="add-link-form">
|
||||||
<div class="typeahead-container">
|
<app-node-picker
|
||||||
<input
|
placeholder="Search by ID or title..."
|
||||||
class="edit-input"
|
submitLabel="Link"
|
||||||
[(ngModel)]="newLinkTargetId"
|
[excludeIds]="linkExcludeIds"
|
||||||
placeholder="Search by ID or title..."
|
(picked)="onLinkPicked($event)"
|
||||||
(input)="onLinkSearchInput()"
|
/>
|
||||||
(focus)="onLinkSearchFocus()"
|
|
||||||
(keydown)="onLinkSearchKeydown($event)"
|
|
||||||
(keydown.enter)="highlightedIndex < 0 && createLink()"
|
|
||||||
(blur)="showLinkDropdown = false"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
@if (showLinkDropdown) {
|
|
||||||
<div class="typeahead-dropdown">
|
|
||||||
@for (req of filteredRequirements; track req.id; let i = $index) {
|
|
||||||
<div
|
|
||||||
class="typeahead-option"
|
|
||||||
[class.highlighted]="i === highlightedIndex"
|
|
||||||
(mousedown)="selectLinkTarget(req)"
|
|
||||||
>
|
|
||||||
<span class="typeahead-id">{{ req.id }}</span>
|
|
||||||
<span class="typeahead-title">{{ req.title }}</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
@if (linkError) {
|
@if (linkError) {
|
||||||
<div class="create-error">{{ linkError }}</div>
|
<div class="create-error">{{ linkError }}</div>
|
||||||
}
|
}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button class="action-btn primary small" (click)="createLink()">Link</button>
|
|
||||||
<button class="action-btn small" (click)="toggleAddLink()">Cancel</button>
|
<button class="action-btn small" (click)="toggleAddLink()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,45 +361,27 @@
|
|||||||
<div class="history-detail">
|
<div class="history-detail">
|
||||||
<h4 class="history-detail-title">Version {{ selectedVersion.version }} Details</h4>
|
<h4 class="history-detail-title">Version {{ selectedVersion.version }} Details</h4>
|
||||||
<div class="history-fields">
|
<div class="history-fields">
|
||||||
<div class="history-field" [class.changed]="isFieldChanged('title')">
|
@for (field of [
|
||||||
<span class="history-field-label">Title</span>
|
{key: 'title', label: 'Title'},
|
||||||
<span class="history-field-value">{{ selectedVersion.title }}</span>
|
{key: 'description', label: 'Description'},
|
||||||
</div>
|
{key: 'priority', label: 'Priority'},
|
||||||
<div class="history-field" [class.changed]="isFieldChanged('description')">
|
{key: 'status', label: 'Status'},
|
||||||
<span class="history-field-label">Description</span>
|
{key: 'node_type', label: 'Node Type'},
|
||||||
<span class="history-field-value">{{ selectedVersion.description || '(empty)' }}</span>
|
{key: 'acceptance_criteria', label: 'Acceptance Criteria'},
|
||||||
</div>
|
{key: 'test_spec', label: 'Test Spec'},
|
||||||
<div class="history-field" [class.changed]="isFieldChanged('priority')">
|
{key: 'test_code', label: 'Test Code'},
|
||||||
<span class="history-field-label">Priority</span>
|
{key: 'impl_code', label: 'Impl Code'}
|
||||||
<span class="history-field-value">{{ selectedVersion.priority }}</span>
|
]; track field.key) {
|
||||||
</div>
|
@if (isFieldChanged(field.key)) {
|
||||||
<div class="history-field" [class.changed]="isFieldChanged('status')">
|
<div class="history-field changed">
|
||||||
<span class="history-field-label">Status</span>
|
<span class="history-field-label">{{ field.label }}</span>
|
||||||
<span class="history-field-value">{{ selectedVersion.status }}</span>
|
<div class="history-delta">
|
||||||
</div>
|
<div class="delta-old">{{ getFieldOldValue(field.key) }}</div>
|
||||||
@if (isFieldChanged('acceptance_criteria')) {
|
<span class="delta-arrow">→</span>
|
||||||
<div class="history-field changed">
|
<div class="delta-new">{{ getFieldNewValue(field.key) }}</div>
|
||||||
<span class="history-field-label">Acceptance Criteria</span>
|
</div>
|
||||||
<pre class="history-field-pre">{{ selectedVersion.acceptance_criteria || '(empty)' }}</pre>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
@if (isFieldChanged('test_spec')) {
|
|
||||||
<div class="history-field changed">
|
|
||||||
<span class="history-field-label">Test Spec</span>
|
|
||||||
<pre class="history-field-pre">{{ selectedVersion.test_spec || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (isFieldChanged('test_code')) {
|
|
||||||
<div class="history-field changed">
|
|
||||||
<span class="history-field-label">Test Code</span>
|
|
||||||
<pre class="history-field-pre">{{ selectedVersion.test_code || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (isFieldChanged('impl_code')) {
|
|
||||||
<div class="history-field changed">
|
|
||||||
<span class="history-field-label">Impl Code</span>
|
|
||||||
<pre class="history-field-pre">{{ selectedVersion.impl_code || '(empty)' }}</pre>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button class="action-btn primary small" (click)="restoreVersion(selectedVersion.version)">Restore this version</button>
|
<button class="action-btn primary small" (click)="restoreVersion(selectedVersion.version)">Restore this version</button>
|
||||||
@@ -409,3 +402,50 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (showDeleteDialog) {
|
||||||
|
<div class="delete-overlay" (click)="showDeleteDialog = false">
|
||||||
|
<div class="delete-dialog" (click)="$event.stopPropagation()">
|
||||||
|
<div class="delete-header">
|
||||||
|
<span>Delete "{{ node.title }}"</span>
|
||||||
|
<button class="delete-close" (click)="showDeleteDialog = false">x</button>
|
||||||
|
</div>
|
||||||
|
<div class="delete-body">
|
||||||
|
<p class="delete-desc">This node has {{ deleteChildren.length }} direct {{ deleteChildren.length === 1 ? 'child' : 'children' }}. Choose what happens to each:</p>
|
||||||
|
<div class="delete-bulk-actions">
|
||||||
|
<button class="bulk-btn" (click)="setAllDeleteActions('orphan')">Orphan All</button>
|
||||||
|
<button class="bulk-btn danger" (click)="setAllDeleteActions('delete')">Delete All</button>
|
||||||
|
</div>
|
||||||
|
<div class="delete-children-list">
|
||||||
|
@for (entry of deleteChildren; track entry.node.id) {
|
||||||
|
<div class="delete-child-row">
|
||||||
|
<div class="delete-child-info">
|
||||||
|
<span class="delete-child-id">{{ entry.node.id }}</span>
|
||||||
|
<span class="delete-child-title">{{ entry.node.title }}</span>
|
||||||
|
@if (entry.descendantCount > 0) {
|
||||||
|
<span class="delete-child-desc">+{{ entry.descendantCount }} nested</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="delete-child-toggle">
|
||||||
|
<button
|
||||||
|
class="toggle-btn"
|
||||||
|
[class.active]="entry.action === 'orphan'"
|
||||||
|
(click)="entry.action = 'orphan'"
|
||||||
|
>Orphan</button>
|
||||||
|
<button
|
||||||
|
class="toggle-btn danger"
|
||||||
|
[class.active]="entry.action === 'delete'"
|
||||||
|
(click)="entry.action = 'delete'"
|
||||||
|
>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="delete-footer">
|
||||||
|
<button class="action-btn danger" (click)="confirmDelete()">Confirm Delete</button>
|
||||||
|
<button class="action-btn" (click)="showDeleteDialog = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -496,57 +496,6 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typeahead dropdown
|
|
||||||
.typeahead-container {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typeahead-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 6px 6px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
z-index: 20;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.typeahead-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover, &.highlighted {
|
|
||||||
background: rgba(188, 140, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.typeahead-id {
|
|
||||||
color: #bc8cff;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typeahead-title {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// History styles
|
// History styles
|
||||||
.clickable-title {
|
.clickable-title {
|
||||||
@@ -666,3 +615,221 @@
|
|||||||
max-height: 150px;
|
max-height: 150px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-delta {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-old {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
flex: 1;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-arrow {
|
||||||
|
color: var(--yellow);
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-new {
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Orphan / Re-parent ──────────────────────────────────────────
|
||||||
|
.orphan-banner {
|
||||||
|
background: rgba(210, 153, 34, 0.08);
|
||||||
|
border: 1px dashed #d29922;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d29922;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.reparent-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--red);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.section-action {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
&:hover { text-decoration: underline; }
|
||||||
|
&.danger { color: var(--red, #f85149); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Delete Dialog ────────────────────────────────────────────────
|
||||||
|
.delete-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 480px;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
&:hover { color: var(--text-primary); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-body {
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { color: var(--text-primary); border-color: var(--text-muted); }
|
||||||
|
&.danger { color: var(--red); &:hover { border-color: var(--red); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-children-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-child-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-child-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-child-id {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-child-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-child-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-child-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.danger.active {
|
||||||
|
color: var(--red);
|
||||||
|
border-color: var(--red);
|
||||||
|
background: rgba(218, 54, 51, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { RequirementService } from '../../core/services/requirement.service';
|
import { RequirementService } from '../../core/services/requirement.service';
|
||||||
|
import { NodePickerComponent } from '../../shared/node-picker.component';
|
||||||
import {
|
import {
|
||||||
RequirementNode,
|
RequirementNode,
|
||||||
CrossPillarLink,
|
CrossPillarLink,
|
||||||
@@ -27,7 +28,7 @@ import {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-detail-panel',
|
selector: 'app-detail-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule],
|
imports: [CommonModule, FormsModule, NodePickerComponent],
|
||||||
templateUrl: './detail-panel.component.html',
|
templateUrl: './detail-panel.component.html',
|
||||||
styleUrl: './detail-panel.component.scss',
|
styleUrl: './detail-panel.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -57,17 +58,21 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
historyList: RequirementHistorySummary[] = [];
|
historyList: RequirementHistorySummary[] = [];
|
||||||
showHistory = false;
|
showHistory = false;
|
||||||
selectedVersion: RequirementHistoryDetail | null = null;
|
selectedVersion: RequirementHistoryDetail | null = null;
|
||||||
|
previousVersion: RequirementHistoryDetail | null = null;
|
||||||
|
|
||||||
|
// Delete dialog
|
||||||
|
showDeleteDialog = false;
|
||||||
|
deleteChildren: { node: RequirementNode; action: 'orphan' | 'delete'; descendantCount: number }[] = [];
|
||||||
|
|
||||||
|
// Re-parent
|
||||||
|
showReparent = false;
|
||||||
|
reparentError: string | null = null;
|
||||||
|
|
||||||
// Link management
|
// Link management
|
||||||
outgoingLinks: CrossPillarLink[] = []; // this node "also satisfies" these
|
outgoingLinks: CrossPillarLink[] = [];
|
||||||
incomingLinks: CrossPillarLink[] = []; // these nodes satisfy this one
|
incomingLinks: CrossPillarLink[] = [];
|
||||||
showAddLink = false;
|
showAddLink = false;
|
||||||
newLinkTargetId = '';
|
|
||||||
linkError: string | null = null;
|
linkError: string | null = null;
|
||||||
allRequirements: { id: string; title: string }[] = [];
|
|
||||||
filteredRequirements: { id: string; title: string }[] = [];
|
|
||||||
showLinkDropdown = false;
|
|
||||||
highlightedIndex = -1;
|
|
||||||
|
|
||||||
get effectiveMeasurable(): boolean {
|
get effectiveMeasurable(): boolean {
|
||||||
return this.validationReport ? this.validationReport.measurable : this.node.measurable;
|
return this.validationReport ? this.validationReport.measurable : this.node.measurable;
|
||||||
@@ -86,9 +91,14 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
this.editing = false;
|
this.editing = false;
|
||||||
this.showAddChild = false;
|
this.showAddChild = false;
|
||||||
this.showAddLink = false;
|
this.showAddLink = false;
|
||||||
|
this.showReparent = false;
|
||||||
|
this.showReparent = false;
|
||||||
|
this.reparentError = null;
|
||||||
|
this.showDeleteDialog = false;
|
||||||
this.showHistory = false;
|
this.showHistory = false;
|
||||||
this.historyList = [];
|
this.historyList = [];
|
||||||
this.selectedVersion = null;
|
this.selectedVersion = null;
|
||||||
|
this.previousVersion = null;
|
||||||
this.validationReport = this.node.quality || null;
|
this.validationReport = this.node.quality || null;
|
||||||
this.loadParent();
|
this.loadParent();
|
||||||
this.loadChildren();
|
this.loadChildren();
|
||||||
@@ -205,9 +215,81 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteNode(): void {
|
deleteNode(): void {
|
||||||
if (!confirm(`Delete "${this.node.title}" and all its children?`)) return;
|
if (this.children.length === 0) {
|
||||||
this.reqService.deleteRequirement(this.node.id).subscribe({
|
// No children — delete immediately with simple confirm
|
||||||
next: () => this.nodeDeleted.emit(this.node.id),
|
if (!confirm(`Delete "${this.node.title}"?`)) return;
|
||||||
|
this.reqService.deleteRequirement(this.node.id).subscribe({
|
||||||
|
next: () => this.nodeDeleted.emit(this.node.id),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Has children — show the delete dialog
|
||||||
|
this.deleteChildren = this.children.map(c => ({
|
||||||
|
node: c,
|
||||||
|
action: 'orphan' as const,
|
||||||
|
descendantCount: this.countDescendants(c),
|
||||||
|
}));
|
||||||
|
this.showDeleteDialog = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
private countDescendants(node: RequirementNode): number {
|
||||||
|
let count = 0;
|
||||||
|
if (node.children) {
|
||||||
|
count = node.children.length;
|
||||||
|
for (const child of node.children) {
|
||||||
|
count += this.countDescendants(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllDeleteActions(action: 'orphan' | 'delete'): void {
|
||||||
|
this.deleteChildren.forEach(c => c.action = action);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(): void {
|
||||||
|
const deleteIds = this.deleteChildren
|
||||||
|
.filter(c => c.action === 'delete')
|
||||||
|
.map(c => c.node.id);
|
||||||
|
this.reqService.deleteRequirement(this.node.id, deleteIds).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.showDeleteDialog = false;
|
||||||
|
this.nodeDeleted.emit(this.node.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Re-parent ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
unlinkParent(): void {
|
||||||
|
this.reqService.updateRequirement(this.node.id, { parent_id: null } as any).subscribe({
|
||||||
|
next: (updated) => {
|
||||||
|
Object.assign(this.node, updated);
|
||||||
|
this.validationReport = updated.quality || null;
|
||||||
|
this.parentNode = null;
|
||||||
|
this.nodeUpdated.emit(updated);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onReparentPicked(newParentId: string): void {
|
||||||
|
this.reparentError = null;
|
||||||
|
this.reqService.updateRequirement(this.node.id, { parent_id: newParentId } as any).subscribe({
|
||||||
|
next: (updated) => {
|
||||||
|
this.showReparent = false;
|
||||||
|
Object.assign(this.node, updated);
|
||||||
|
this.validationReport = updated.quality || null;
|
||||||
|
this.loadParent();
|
||||||
|
this.loadChildren();
|
||||||
|
this.nodeUpdated.emit(updated);
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.reparentError = err?.error?.detail || 'Failed to re-parent';
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,72 +307,14 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
|
|
||||||
toggleAddLink(): void {
|
toggleAddLink(): void {
|
||||||
this.showAddLink = !this.showAddLink;
|
this.showAddLink = !this.showAddLink;
|
||||||
this.newLinkTargetId = '';
|
|
||||||
this.linkError = null;
|
this.linkError = null;
|
||||||
this.showLinkDropdown = false;
|
|
||||||
this.highlightedIndex = -1;
|
|
||||||
if (this.showAddLink) {
|
|
||||||
this.reqService.getRequirements().subscribe({
|
|
||||||
next: (reqs) => {
|
|
||||||
// Exclude self and already-linked targets
|
|
||||||
const linkedIds = new Set(this.outgoingLinks.map(l => l.target_id));
|
|
||||||
linkedIds.add(this.node.id);
|
|
||||||
this.allRequirements = reqs
|
|
||||||
.filter(r => !linkedIds.has(r.id))
|
|
||||||
.map(r => ({ id: r.id, title: r.title }));
|
|
||||||
this.filteredRequirements = [];
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onLinkSearchInput(): void {
|
get linkExcludeIds(): string[] {
|
||||||
this.filterLinkOptions();
|
return [this.node.id, ...this.outgoingLinks.map(l => l.target_id)];
|
||||||
}
|
}
|
||||||
|
|
||||||
onLinkSearchFocus(): void {
|
onLinkPicked(targetId: string): void {
|
||||||
this.filterLinkOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterLinkOptions(): void {
|
|
||||||
const q = this.newLinkTargetId.toLowerCase().trim();
|
|
||||||
if (!q) {
|
|
||||||
this.filteredRequirements = this.allRequirements.slice(0, 10);
|
|
||||||
} else {
|
|
||||||
this.filteredRequirements = this.allRequirements
|
|
||||||
.filter(r => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q))
|
|
||||||
.slice(0, 10);
|
|
||||||
}
|
|
||||||
this.showLinkDropdown = this.filteredRequirements.length > 0;
|
|
||||||
this.highlightedIndex = -1;
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
onLinkSearchKeydown(event: KeyboardEvent): void {
|
|
||||||
if (!this.showLinkDropdown) return;
|
|
||||||
if (event.key === 'ArrowDown') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filteredRequirements.length - 1);
|
|
||||||
} else if (event.key === 'ArrowUp') {
|
|
||||||
event.preventDefault();
|
|
||||||
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
|
|
||||||
} else if (event.key === 'Enter' && this.highlightedIndex >= 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.selectLinkTarget(this.filteredRequirements[this.highlightedIndex]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectLinkTarget(req: { id: string; title: string }): void {
|
|
||||||
this.newLinkTargetId = req.id;
|
|
||||||
this.showLinkDropdown = false;
|
|
||||||
this.highlightedIndex = -1;
|
|
||||||
this.cdr.markForCheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
createLink(): void {
|
|
||||||
const targetId = this.newLinkTargetId.trim();
|
|
||||||
if (!targetId) return;
|
|
||||||
this.reqService.createLink({
|
this.reqService.createLink({
|
||||||
source_id: this.node.id,
|
source_id: this.node.id,
|
||||||
target_id: targetId,
|
target_id: targetId,
|
||||||
@@ -298,13 +322,13 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.showAddLink = false;
|
this.showAddLink = false;
|
||||||
this.newLinkTargetId = '';
|
|
||||||
this.linkError = null;
|
this.linkError = null;
|
||||||
this.loadLinks();
|
this.loadLinks();
|
||||||
this.linksChanged.emit();
|
this.linksChanged.emit();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.linkError = err?.error?.detail || 'Failed to create link';
|
this.linkError = err?.error?.detail || 'Failed to create link';
|
||||||
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -335,6 +359,7 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
toggleHistory(): void {
|
toggleHistory(): void {
|
||||||
this.showHistory = !this.showHistory;
|
this.showHistory = !this.showHistory;
|
||||||
this.selectedVersion = null;
|
this.selectedVersion = null;
|
||||||
|
this.previousVersion = null;
|
||||||
if (this.showHistory) {
|
if (this.showHistory) {
|
||||||
this.loadHistory();
|
this.loadHistory();
|
||||||
}
|
}
|
||||||
@@ -352,12 +377,23 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
viewVersion(version: number): void {
|
viewVersion(version: number): void {
|
||||||
if (this.selectedVersion?.version === version) {
|
if (this.selectedVersion?.version === version) {
|
||||||
this.selectedVersion = null;
|
this.selectedVersion = null;
|
||||||
|
this.previousVersion = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.reqService.getHistoryVersion(this.node.id, version).subscribe({
|
this.reqService.getHistoryVersion(this.node.id, version).subscribe({
|
||||||
next: (detail) => {
|
next: (detail) => {
|
||||||
this.selectedVersion = detail;
|
this.selectedVersion = detail;
|
||||||
this.cdr.markForCheck();
|
if (version > 1) {
|
||||||
|
this.reqService.getHistoryVersion(this.node.id, version - 1).subscribe({
|
||||||
|
next: (prev) => {
|
||||||
|
this.previousVersion = prev;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.previousVersion = null;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -370,6 +406,7 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
this.validationReport = restored.quality || null;
|
this.validationReport = restored.quality || null;
|
||||||
this.nodeUpdated.emit(restored);
|
this.nodeUpdated.emit(restored);
|
||||||
this.selectedVersion = null;
|
this.selectedVersion = null;
|
||||||
|
this.previousVersion = null;
|
||||||
this.loadHistory();
|
this.loadHistory();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
@@ -378,7 +415,21 @@ export class DetailPanelComponent implements OnChanges {
|
|||||||
|
|
||||||
isFieldChanged(field: string): boolean {
|
isFieldChanged(field: string): boolean {
|
||||||
if (!this.selectedVersion) return false;
|
if (!this.selectedVersion) return false;
|
||||||
return (this.selectedVersion as any)[field] !== (this.node as any)[field];
|
if (this.previousVersion) {
|
||||||
|
return (this.selectedVersion as any)[field] !== (this.previousVersion as any)[field];
|
||||||
|
}
|
||||||
|
// Version 1 — everything is "new"
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFieldOldValue(field: string): string {
|
||||||
|
if (!this.previousVersion) return '(none)';
|
||||||
|
return (this.previousVersion as any)[field] || '(empty)';
|
||||||
|
}
|
||||||
|
|
||||||
|
getFieldNewValue(field: string): string {
|
||||||
|
if (!this.selectedVersion) return '';
|
||||||
|
return (this.selectedVersion as any)[field] || '(empty)';
|
||||||
}
|
}
|
||||||
|
|
||||||
goToNode(nodeId: string): void {
|
goToNode(nodeId: string): void {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<button class="toolbar-btn" (click)="zoomOut()" title="Zoom Out">-</button>
|
<button class="toolbar-btn" (click)="zoomOut()" title="Zoom Out">-</button>
|
||||||
<button class="toolbar-btn" (click)="fitGraph()" title="Fit to Screen">Fit</button>
|
<button class="toolbar-btn" (click)="fitGraph()" title="Fit to Screen">Fit</button>
|
||||||
<button class="toolbar-btn" (click)="relayout()" title="Re-layout">Layout</button>
|
<button class="toolbar-btn" (click)="relayout()" title="Re-layout">Layout</button>
|
||||||
|
<button class="toolbar-btn warn" (click)="groupOrphans()" title="Group orphaned nodes together">Orphans</button>
|
||||||
<div class="layout-controls">
|
<div class="layout-controls">
|
||||||
<button class="toolbar-btn" (click)="showSaveLayout = true" title="Save Layout">Save</button>
|
<button class="toolbar-btn" (click)="showSaveLayout = true" title="Save Layout">Save</button>
|
||||||
@if (savedLayouts.length > 0) {
|
@if (savedLayouts.length > 0) {
|
||||||
@@ -55,16 +56,17 @@
|
|||||||
<button class="toolbar-btn" (click)="showManageLayouts = true" title="Manage Layouts">Layouts</button>
|
<button class="toolbar-btn" (click)="showManageLayouts = true" title="Manage Layouts">Layouts</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<button class="toolbar-btn help-btn" (click)="showShortcuts = !showShortcuts" title="Keyboard & Mouse Shortcuts">⌨</button>
|
||||||
<button class="toolbar-btn help-btn" (click)="showHelp = !showHelp" title="Help">?</button>
|
<button class="toolbar-btn help-btn" (click)="showHelp = !showHelp" title="Help">?</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showHelp) {
|
@if (showShortcuts) {
|
||||||
<div class="help-overlay" (click)="showHelp = false">
|
<div class="help-overlay" (click)="showShortcuts = false">
|
||||||
<div class="help-dialog" (click)="$event.stopPropagation()">
|
<div class="help-dialog" (click)="$event.stopPropagation()">
|
||||||
<div class="help-header">
|
<div class="help-header">
|
||||||
<span>Keyboard & Mouse Shortcuts</span>
|
<span>Keyboard & Mouse Shortcuts</span>
|
||||||
<button class="help-close" (click)="showHelp = false">x</button>
|
<button class="help-close" (click)="showShortcuts = false">x</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="help-body">
|
<div class="help-body">
|
||||||
<div class="help-section">Mouse</div>
|
<div class="help-section">Mouse</div>
|
||||||
@@ -87,6 +89,7 @@
|
|||||||
<div class="help-section">Graph</div>
|
<div class="help-section">Graph</div>
|
||||||
<div class="help-row"><span class="help-key">Solid lines</span><span>Parent-child relationships</span></div>
|
<div class="help-row"><span class="help-key">Solid lines</span><span>Parent-child relationships</span></div>
|
||||||
<div class="help-row"><span class="help-key">Dashed purple</span><span>"Also satisfies" links</span></div>
|
<div class="help-row"><span class="help-key">Dashed purple</span><span>"Also satisfies" links</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">Dashed yellow</span><span>Orphaned node (parent was deleted)</span></div>
|
||||||
<div class="help-row"><span class="help-key">Red dot</span><span>Fewer than 2 quality gates pass</span></div>
|
<div class="help-row"><span class="help-key">Red dot</span><span>Fewer than 2 quality gates pass</span></div>
|
||||||
<div class="help-row"><span class="help-key">Orange dot</span><span>2 of 3 quality gates pass</span></div>
|
<div class="help-row"><span class="help-key">Orange dot</span><span>2 of 3 quality gates pass</span></div>
|
||||||
<div class="help-row"><span class="help-key">Green dot</span><span>All quality gates pass</span></div>
|
<div class="help-row"><span class="help-key">Green dot</span><span>All quality gates pass</span></div>
|
||||||
@@ -95,6 +98,54 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (showHelp) {
|
||||||
|
<div class="help-overlay" (click)="showHelp = false">
|
||||||
|
<div class="help-dialog wide" (click)="$event.stopPropagation()">
|
||||||
|
<div class="help-header">
|
||||||
|
<span>How It Works</span>
|
||||||
|
<button class="help-close" (click)="showHelp = false">x</button>
|
||||||
|
</div>
|
||||||
|
<div class="help-body">
|
||||||
|
<div class="help-section">Databases</div>
|
||||||
|
<div class="help-text">
|
||||||
|
Each project lives in its own SQLite <code>.db</code> file. Use the database selector
|
||||||
|
in the navbar to switch between them.
|
||||||
|
</div>
|
||||||
|
<div class="help-row"><span class="help-key">New</span><span>Creates an empty database on the server</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">Copy</span><span>Clones the active database under a new name</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">Upload</span><span>Uploads a <code>.db</code> file from your computer to the server and switches to it</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">Download</span><span>Downloads the active database as a local backup file</span></div>
|
||||||
|
|
||||||
|
<div class="help-section">Layout Persistence</div>
|
||||||
|
<div class="help-text">
|
||||||
|
Node positions, viewport (zoom/pan), selected node, and panel state are automatically
|
||||||
|
saved to the active database every time you make a change. This means your layout
|
||||||
|
travels with the <code>.db</code> file — download it, upload to another machine or
|
||||||
|
browser, and everything is exactly where you left it.
|
||||||
|
</div>
|
||||||
|
<div class="help-row"><span class="help-key">Auto-save</span><span>Working state writes to the DB after 1 second of inactivity</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">Named layouts</span><span>Save/Load snapshots of node positions via the toolbar</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">Layout button</span><span>Resets the tree to an automatic dagre layout; orphans are grouped below</span></div>
|
||||||
|
|
||||||
|
<div class="help-section">Deleting Nodes</div>
|
||||||
|
<div class="help-text">
|
||||||
|
Deleting a requirement does <strong>not</strong> delete its children. Children become
|
||||||
|
<strong>orphans</strong> — they lose their parent link and show a dashed yellow border.
|
||||||
|
Orphaned nodes fail the Traceable (T) quality gate until re-parented.
|
||||||
|
</div>
|
||||||
|
<div class="help-row"><span class="help-key">Orphans button</span><span>Groups orphaned nodes together without moving the rest of the layout</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">Re-parent</span><span>Edit an orphan's parent ID in the detail panel to restore traceability</span></div>
|
||||||
|
|
||||||
|
<div class="help-section">Quality Gates (TMCS)</div>
|
||||||
|
<div class="help-row"><span class="help-key">T — Traceable</span><span>Auto-calculated: non-pillar nodes must have a parent</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">M — Measurable</span><span>Requires a test specification (GIVEN/WHEN/THEN)</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">C — Consistent</span><span>Manual toggle — you verify it doesn't contradict other requirements</span></div>
|
||||||
|
<div class="help-row"><span class="help-key">S — Single-purpose</span><span>Manual toggle — you verify it serves exactly one purpose</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (showCreatePillar) {
|
@if (showCreatePillar) {
|
||||||
<div class="create-pillar-bar">
|
<div class="create-pillar-bar">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -260,6 +260,10 @@
|
|||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
&.wide {
|
||||||
|
width: 560px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-header {
|
.help-header {
|
||||||
@@ -304,6 +308,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.help-row {
|
.help-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
@@ -440,3 +460,12 @@
|
|||||||
border-color: var(--red);
|
border-color: var(--red);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar-btn.warn {
|
||||||
|
color: #d29922;
|
||||||
|
border-color: rgba(210, 153, 34, 0.3);
|
||||||
|
&:hover {
|
||||||
|
background: rgba(210, 153, 34, 0.15);
|
||||||
|
border-color: #d29922;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import cytoscape, { Core, NodeSingular, Collection } from 'cytoscape';
|
|||||||
import dagre from 'cytoscape-dagre';
|
import dagre from 'cytoscape-dagre';
|
||||||
import { RequirementService } from '../../core/services/requirement.service';
|
import { RequirementService } from '../../core/services/requirement.service';
|
||||||
import { DetailPanelComponent } from '../detail/detail-panel.component';
|
import { DetailPanelComponent } from '../detail/detail-panel.component';
|
||||||
import { RequirementNode, RequirementTreeNode, CrossPillarLink, PRIORITY_COLORS, Priority, GraphLayoutResponse } from '../../core/models/requirement.model';
|
import { RequirementNode, RequirementTreeNode, CrossPillarLink, PRIORITY_COLORS, Priority, GraphLayoutResponse, DatabaseInfo } from '../../core/models/requirement.model';
|
||||||
import { Subject, takeUntil, forkJoin } from 'rxjs';
|
import { Subject, takeUntil, forkJoin, debounceTime } from 'rxjs';
|
||||||
|
|
||||||
cytoscape.use(dagre);
|
cytoscape.use(dagre);
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
treeData: RequirementTreeNode[] = [];
|
treeData: RequirementTreeNode[] = [];
|
||||||
loading = true;
|
loading = true;
|
||||||
showCreatePillar = false;
|
showCreatePillar = false;
|
||||||
|
showShortcuts = false;
|
||||||
showHelp = false;
|
showHelp = false;
|
||||||
isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||||
newPillar = { id: '', title: '', description: '' };
|
newPillar = { id: '', title: '', description: '' };
|
||||||
@@ -85,6 +86,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
newLayoutDescription = '';
|
newLayoutDescription = '';
|
||||||
newLayoutDefault = false;
|
newLayoutDefault = false;
|
||||||
|
|
||||||
|
// Database-scoped localStorage prefix
|
||||||
|
private dbName = 'planner';
|
||||||
|
|
||||||
|
// Debounced autosave to DB
|
||||||
|
private autosave$ = new Subject<void>();
|
||||||
|
|
||||||
// Canvas state history (Ctrl+Z to undo)
|
// Canvas state history (Ctrl+Z to undo)
|
||||||
private canvasHistory: CanvasSnapshot[] = [];
|
private canvasHistory: CanvasSnapshot[] = [];
|
||||||
private readonly MAX_HISTORY = 20;
|
private readonly MAX_HISTORY = 20;
|
||||||
@@ -99,8 +106,42 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.initCytoscape();
|
this.initCytoscape();
|
||||||
this.loadTree();
|
this.initAutosavePipeline();
|
||||||
this.loadSavedLayouts();
|
this.reqService.getActiveDatabase().subscribe({
|
||||||
|
next: (db) => { this.dbName = db.name; },
|
||||||
|
complete: () => {
|
||||||
|
this.loadTree();
|
||||||
|
this.loadSavedLayouts();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private initAutosavePipeline(): void {
|
||||||
|
this.autosave$.pipe(
|
||||||
|
debounceTime(1000),
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
).subscribe(() => this.flushAutosaveToDb());
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushAutosaveToDb(): void {
|
||||||
|
const positions: Record<string, { x: number; y: number }> = {};
|
||||||
|
this.cy.nodes().forEach((node: NodeSingular) => {
|
||||||
|
const pos = node.position();
|
||||||
|
positions[node.id()] = { x: pos.x, y: pos.y };
|
||||||
|
});
|
||||||
|
const viewport: Record<string, any> = {
|
||||||
|
zoom: this.cy.zoom(),
|
||||||
|
pan: this.cy.pan(),
|
||||||
|
selected_node_id: this.selectedNode?.id ?? null,
|
||||||
|
panel_open: this.detailOpen,
|
||||||
|
};
|
||||||
|
this.reqService.saveAutosaveLayout({ node_positions: positions, viewport }).pipe(
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
).subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private dbKey(key: string): string {
|
||||||
|
return `req-planner-${this.dbName}-${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
@@ -223,11 +264,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
zoom: this.cy.zoom(),
|
zoom: this.cy.zoom(),
|
||||||
pan: this.cy.pan(),
|
pan: this.cy.pan(),
|
||||||
};
|
};
|
||||||
localStorage.setItem('req-planner-viewport', JSON.stringify(viewport));
|
localStorage.setItem(this.dbKey('viewport'), JSON.stringify(viewport));
|
||||||
|
this.autosave$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreViewport(): boolean {
|
private restoreViewport(): boolean {
|
||||||
const saved = localStorage.getItem('req-planner-viewport');
|
const saved = localStorage.getItem(this.dbKey('viewport'));
|
||||||
if (!saved) return false;
|
if (!saved) return false;
|
||||||
try {
|
try {
|
||||||
const { zoom, pan } = JSON.parse(saved);
|
const { zoom, pan } = JSON.parse(saved);
|
||||||
@@ -247,11 +289,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
const pos = node.position();
|
const pos = node.position();
|
||||||
positions[node.id()] = { x: pos.x, y: pos.y };
|
positions[node.id()] = { x: pos.x, y: pos.y };
|
||||||
});
|
});
|
||||||
localStorage.setItem('req-planner-node-positions', JSON.stringify(positions));
|
localStorage.setItem(this.dbKey('node-positions'), JSON.stringify(positions));
|
||||||
|
this.autosave$.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreNodePositions(): boolean {
|
private restoreNodePositions(): boolean {
|
||||||
const saved = localStorage.getItem('req-planner-node-positions');
|
const saved = localStorage.getItem(this.dbKey('node-positions'));
|
||||||
if (!saved) return false;
|
if (!saved) return false;
|
||||||
try {
|
try {
|
||||||
const positions: Record<string, { x: number; y: number }> = JSON.parse(saved);
|
const positions: Record<string, { x: number; y: number }> = JSON.parse(saved);
|
||||||
@@ -403,6 +446,14 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
'border-color': '#d29922',
|
'border-color': '#d29922',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
selector: 'node.orphan',
|
||||||
|
style: {
|
||||||
|
'border-style': 'dashed',
|
||||||
|
'border-color': '#d29922',
|
||||||
|
'border-width': 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +487,7 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
const borderColor = this.getPriorityColor(node.priority);
|
const borderColor = this.getPriorityColor(node.priority);
|
||||||
const dims = this.getNodeDimensions(node.node_type);
|
const dims = this.getNodeDimensions(node.node_type);
|
||||||
const qualityLabel = this.getQualityLabel(node);
|
const qualityLabel = this.getQualityLabel(node);
|
||||||
|
const isOrphan = !node.parent_id && node.node_type !== 'pillar';
|
||||||
elements.push({
|
elements.push({
|
||||||
data: {
|
data: {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
@@ -453,9 +505,12 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
measurable: node.measurable,
|
measurable: node.measurable,
|
||||||
consistent: node.consistent,
|
consistent: node.consistent,
|
||||||
singlePurpose: node.single_purpose,
|
singlePurpose: node.single_purpose,
|
||||||
|
parent_id: node.parent_id || null,
|
||||||
hasGiteaLink: !!node.gitea_issue_number,
|
hasGiteaLink: !!node.gitea_issue_number,
|
||||||
giteaBadge: this.getGiteaBadgeDataUri(!!node.gitea_issue_number),
|
giteaBadge: this.getGiteaBadgeDataUri(!!node.gitea_issue_number),
|
||||||
|
orphan: isOrphan,
|
||||||
},
|
},
|
||||||
|
classes: isOrphan ? 'orphan' : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (node.parent_id) {
|
if (node.parent_id) {
|
||||||
@@ -490,7 +545,42 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
this.cy.elements().remove();
|
this.cy.elements().remove();
|
||||||
this.cy.add(elements);
|
this.cy.add(elements);
|
||||||
|
|
||||||
// Try restoring saved positions; fall back to dagre layout
|
// Try restoring from DB autosave first, then localStorage, then dagre
|
||||||
|
this.reqService.getAutosaveLayout().pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
|
next: (autosave) => {
|
||||||
|
const vp = autosave.viewport as Record<string, any>;
|
||||||
|
if (this.restoreFromLayout(autosave.node_positions, vp)) {
|
||||||
|
this.updateNodeAppearance();
|
||||||
|
this.restoreSelectedNode(vp);
|
||||||
|
} else {
|
||||||
|
this.restoreWithLocalStorageOrDagre();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// No autosave in DB — fall back to localStorage / dagre
|
||||||
|
this.restoreWithLocalStorageOrDagre();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreFromLayout(
|
||||||
|
positions: Record<string, { x: number; y: number }>,
|
||||||
|
viewport: Record<string, any>,
|
||||||
|
): boolean {
|
||||||
|
let restored = 0;
|
||||||
|
const total = this.cy.nodes().length;
|
||||||
|
this.cy.nodes().forEach((node: NodeSingular) => {
|
||||||
|
const pos = positions[node.id()];
|
||||||
|
if (pos) { node.position(pos); restored++; }
|
||||||
|
});
|
||||||
|
if (restored !== total) return false;
|
||||||
|
this.restoringViewport = true;
|
||||||
|
this.cy.viewport({ zoom: viewport['zoom'], pan: viewport['pan'] });
|
||||||
|
this.restoringViewport = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreWithLocalStorageOrDagre(): void {
|
||||||
if (this.restoreNodePositions()) {
|
if (this.restoreNodePositions()) {
|
||||||
if (!this.restoreViewport()) {
|
if (!this.restoreViewport()) {
|
||||||
this.cy.fit(undefined, 40);
|
this.cy.fit(undefined, 40);
|
||||||
@@ -499,7 +589,6 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
this.restoreSelectedNode();
|
this.restoreSelectedNode();
|
||||||
} else {
|
} else {
|
||||||
this.restoringViewport = true;
|
this.restoringViewport = true;
|
||||||
// Exclude satisfaction links from layout so they don't pull nodes out of tree order
|
|
||||||
const layoutEles = this.cy.elements().filter((ele: any) => {
|
const layoutEles = this.cy.elements().filter((ele: any) => {
|
||||||
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
|
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -524,12 +613,13 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private restoreSelectedNode(): void {
|
private restoreSelectedNode(viewport?: Record<string, any>): void {
|
||||||
const savedId = localStorage.getItem('req-planner-selected-node');
|
// Try DB autosave state first, then localStorage
|
||||||
|
const savedId = viewport?.['selected_node_id'] ?? localStorage.getItem(this.dbKey('selected-node'));
|
||||||
if (!savedId) return;
|
if (!savedId) return;
|
||||||
const cyNode = this.cy.getElementById(savedId);
|
const cyNode = this.cy.getElementById(savedId);
|
||||||
if (cyNode.length) {
|
if (cyNode.length) {
|
||||||
const panelWasOpen = localStorage.getItem('req-planner-panel-open') === 'true';
|
const panelWasOpen = viewport?.['panel_open'] ?? localStorage.getItem(this.dbKey('panel-open')) === 'true';
|
||||||
this.loadNodeDetail(savedId, panelWasOpen);
|
this.loadNodeDetail(savedId, panelWasOpen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,8 +781,9 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
}, { duration: 300 });
|
}, { duration: 300 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localStorage.setItem('req-planner-selected-node', nodeId);
|
localStorage.setItem(this.dbKey('selected-node'), nodeId);
|
||||||
localStorage.setItem('req-planner-panel-open', String(this.detailOpen));
|
localStorage.setItem(this.dbKey('panel-open'), String(this.detailOpen));
|
||||||
|
this.autosave$.next();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1095,7 +1186,8 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
closeDetail(): void {
|
closeDetail(): void {
|
||||||
this.detailOpen = false;
|
this.detailOpen = false;
|
||||||
localStorage.setItem('req-planner-panel-open', 'false');
|
localStorage.setItem(this.dbKey('panel-open'), 'false');
|
||||||
|
this.autosave$.next();
|
||||||
const zoom = this.cy.zoom();
|
const zoom = this.cy.zoom();
|
||||||
const pan = { ...this.cy.pan() };
|
const pan = { ...this.cy.pan() };
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@@ -1107,8 +1199,9 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onNodeUpdated(updated: RequirementNode): void {
|
onNodeUpdated(updated: RequirementNode): void {
|
||||||
// Refresh the node in the graph
|
|
||||||
const cyNode = this.cy.getElementById(updated.id);
|
const cyNode = this.cy.getElementById(updated.id);
|
||||||
|
const oldParentId = cyNode.length ? cyNode.data('parent_id') : null;
|
||||||
|
|
||||||
if (cyNode.length) {
|
if (cyNode.length) {
|
||||||
cyNode.data('label', this.getNodeLabel(updated as any));
|
cyNode.data('label', this.getNodeLabel(updated as any));
|
||||||
cyNode.data('priority', updated.priority);
|
cyNode.data('priority', updated.priority);
|
||||||
@@ -1119,7 +1212,13 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
cyNode.data('hasGiteaLink', !!updated.gitea_issue_number);
|
cyNode.data('hasGiteaLink', !!updated.gitea_issue_number);
|
||||||
cyNode.data('giteaBadge', this.getGiteaBadgeDataUri(!!updated.gitea_issue_number));
|
cyNode.data('giteaBadge', this.getGiteaBadgeDataUri(!!updated.gitea_issue_number));
|
||||||
}
|
}
|
||||||
this.updateNodeAppearance();
|
|
||||||
|
// If parent changed, reload tree to rebuild edges
|
||||||
|
if (updated.parent_id !== oldParentId) {
|
||||||
|
this.loadTree();
|
||||||
|
} else {
|
||||||
|
this.updateNodeAppearance();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onNodeCreated(): void {
|
onNodeCreated(): void {
|
||||||
@@ -1149,13 +1248,15 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
relayout(): void {
|
relayout(): void {
|
||||||
localStorage.removeItem('req-planner-node-positions');
|
localStorage.removeItem(this.dbKey('node-positions'));
|
||||||
// Exclude satisfaction links from layout so they don't pull nodes out of tree order
|
const orphanNodes = this.cy.nodes('[?orphan]');
|
||||||
const layoutEles = this.cy.elements().filter((ele: any) => {
|
// Layout the tree (non-orphan) elements
|
||||||
|
const treeEles = this.cy.elements().filter((ele: any) => {
|
||||||
|
if (ele.isNode() && ele.data('orphan')) return false;
|
||||||
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
|
if (ele.isEdge() && ele.data('edgeType') === 'also-satisfies') return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
layoutEles.layout({
|
treeEles.layout({
|
||||||
name: 'dagre',
|
name: 'dagre',
|
||||||
rankDir: 'TB',
|
rankDir: 'TB',
|
||||||
nodeSep: 60,
|
nodeSep: 60,
|
||||||
@@ -1164,11 +1265,44 @@ export class GraphViewComponent implements OnInit, OnDestroy {
|
|||||||
animate: true,
|
animate: true,
|
||||||
animationDuration: 300,
|
animationDuration: 300,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
|
this.positionOrphansBelow(orphanNodes);
|
||||||
this.saveNodePositions();
|
this.saveNodePositions();
|
||||||
},
|
},
|
||||||
} as any).run();
|
} as any).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groupOrphans(): void {
|
||||||
|
const orphanNodes = this.cy.nodes('[?orphan]');
|
||||||
|
if (orphanNodes.length === 0) return;
|
||||||
|
this.positionOrphansBelow(orphanNodes);
|
||||||
|
this.saveNodePositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private positionOrphansBelow(orphanNodes: any): void {
|
||||||
|
if (orphanNodes.length === 0) return;
|
||||||
|
// Find the bounding box of all non-orphan nodes
|
||||||
|
const nonOrphans = this.cy.nodes().filter((n: NodeSingular) => !n.data('orphan'));
|
||||||
|
let bottomY = 0;
|
||||||
|
let centerX = 0;
|
||||||
|
if (nonOrphans.length > 0) {
|
||||||
|
const bb = nonOrphans.boundingBox();
|
||||||
|
bottomY = bb.y2 + 120;
|
||||||
|
centerX = (bb.x1 + bb.x2) / 2;
|
||||||
|
}
|
||||||
|
// Grid layout for orphans
|
||||||
|
const cols = Math.ceil(Math.sqrt(orphanNodes.length));
|
||||||
|
const spacingX = 200;
|
||||||
|
const spacingY = 100;
|
||||||
|
const startX = centerX - ((cols - 1) * spacingX) / 2;
|
||||||
|
orphanNodes.forEach((node: NodeSingular, i: number) => {
|
||||||
|
const col = i % cols;
|
||||||
|
const row = Math.floor(i / cols);
|
||||||
|
node.animate({
|
||||||
|
position: { x: startX + col * spacingX, y: bottomY + row * spacingY },
|
||||||
|
}, { duration: 300 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Search Typeahead ──────────────────────────────────────────
|
// ─── Search Typeahead ──────────────────────────────────────────
|
||||||
|
|
||||||
private buildSearchIndex(tree: RequirementTreeNode[]): void {
|
private buildSearchIndex(tree: RequirementTreeNode[]): void {
|
||||||
|
|||||||
235
frontend/src/app/shared/node-picker.component.ts
Normal file
235
frontend/src/app/shared/node-picker.component.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
EventEmitter,
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RequirementService } from '../core/services/requirement.service';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-node-picker',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule],
|
||||||
|
template: `
|
||||||
|
<div class="typeahead-container">
|
||||||
|
<div class="typeahead-input-row">
|
||||||
|
<input
|
||||||
|
class="typeahead-input"
|
||||||
|
[(ngModel)]="query"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
(input)="onInput()"
|
||||||
|
(focus)="onFocus()"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
|
(blur)="onBlur()"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="typeahead-submit"
|
||||||
|
[class.primary]="submitStyle === 'primary'"
|
||||||
|
[disabled]="!query.trim()"
|
||||||
|
(click)="submit()"
|
||||||
|
>{{ submitLabel }}</button>
|
||||||
|
</div>
|
||||||
|
@if (showDropdown && filtered.length > 0) {
|
||||||
|
<div class="typeahead-dropdown">
|
||||||
|
@for (item of filtered; track item.id; let i = $index) {
|
||||||
|
<div
|
||||||
|
class="typeahead-option"
|
||||||
|
[class.highlighted]="i === highlightedIndex"
|
||||||
|
(mousedown)="selectItem(item)"
|
||||||
|
>
|
||||||
|
<span class="typeahead-id">{{ item.id }}</span>
|
||||||
|
<span class="typeahead-title">{{ item.title }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.typeahead-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.typeahead-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.typeahead-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
&:focus { border-color: var(--accent); }
|
||||||
|
}
|
||||||
|
.typeahead-submit {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
&:hover { color: var(--text-primary); border-color: var(--text-muted); }
|
||||||
|
&:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
&.primary {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
&:hover { background: rgba(88, 166, 255, 0.1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.typeahead-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
.typeahead-option {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
&:hover, &.highlighted { background: rgba(88, 166, 255, 0.1); }
|
||||||
|
}
|
||||||
|
.typeahead-id {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.typeahead-title {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NodePickerComponent implements OnInit, OnDestroy {
|
||||||
|
@Input() placeholder = 'Search by ID or title...';
|
||||||
|
@Input() submitLabel = 'Select';
|
||||||
|
@Input() submitStyle: 'primary' | 'default' = 'primary';
|
||||||
|
@Input() excludeIds: string[] = [];
|
||||||
|
@Output() picked = new EventEmitter<string>();
|
||||||
|
|
||||||
|
private reqService = inject(RequirementService);
|
||||||
|
private cdr = inject(ChangeDetectorRef);
|
||||||
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
|
query = '';
|
||||||
|
allItems: { id: string; title: string }[] = [];
|
||||||
|
filtered: { id: string; title: string }[] = [];
|
||||||
|
showDropdown = false;
|
||||||
|
highlightedIndex = -1;
|
||||||
|
private loaded = false;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroy$.next();
|
||||||
|
this.destroy$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadItems(): void {
|
||||||
|
this.reqService.getRequirements().pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
|
next: (reqs) => {
|
||||||
|
const excluded = new Set(this.excludeIds);
|
||||||
|
this.allItems = reqs
|
||||||
|
.filter(r => !excluded.has(r.id))
|
||||||
|
.map(r => ({ id: r.id, title: r.title }));
|
||||||
|
this.loaded = true;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput(): void {
|
||||||
|
this.filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
onFocus(): void {
|
||||||
|
if (!this.loaded) this.loadItems();
|
||||||
|
this.filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBlur(): void {
|
||||||
|
// Delay to allow mousedown on dropdown items
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showDropdown = false;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (!this.showDropdown) return;
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightedIndex = Math.min(this.highlightedIndex + 1, this.filtered.length - 1);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
if (this.highlightedIndex >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.selectItem(this.filtered[this.highlightedIndex]);
|
||||||
|
} else {
|
||||||
|
this.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectItem(item: { id: string; title: string }): void {
|
||||||
|
this.query = item.id;
|
||||||
|
this.showDropdown = false;
|
||||||
|
this.highlightedIndex = -1;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
submit(): void {
|
||||||
|
const id = this.query.trim();
|
||||||
|
if (!id) return;
|
||||||
|
this.picked.emit(id);
|
||||||
|
this.query = '';
|
||||||
|
this.showDropdown = false;
|
||||||
|
this.highlightedIndex = -1;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
private filter(): void {
|
||||||
|
const q = this.query.toLowerCase().trim();
|
||||||
|
if (!q) {
|
||||||
|
this.filtered = this.allItems.slice(0, 10);
|
||||||
|
} else {
|
||||||
|
this.filtered = this.allItems
|
||||||
|
.filter(r => r.id.toLowerCase().includes(q) || r.title.toLowerCase().includes(q))
|
||||||
|
.slice(0, 10);
|
||||||
|
}
|
||||||
|
this.showDropdown = this.filtered.length > 0;
|
||||||
|
this.highlightedIndex = -1;
|
||||||
|
this.cdr.markForCheck();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
launch.bat
Normal file
16
launch.bat
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
REM Launch req-planner: backend (FastAPI) + frontend (Angular)
|
||||||
|
|
||||||
|
echo Starting backend on http://localhost:8100 ...
|
||||||
|
start "req-planner-backend" /D "%~dp0backend" cmd /c ".venv\Scripts\python -m uvicorn app.main:app --reload --port 8100"
|
||||||
|
|
||||||
|
echo Starting frontend on http://localhost:4200 ...
|
||||||
|
start "req-planner-frontend" /D "%~dp0frontend" cmd /c "npx ng serve"
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo req-planner running:
|
||||||
|
echo Frontend: http://localhost:4200
|
||||||
|
echo Backend: http://localhost:8100
|
||||||
|
echo API docs: http://localhost:8100/docs
|
||||||
|
echo.
|
||||||
|
echo Close the spawned windows to stop services.
|
||||||
36
launch.sh
Normal file
36
launch.sh
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Launch req-planner: backend (FastAPI) + frontend (Angular)
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "Shutting down..."
|
||||||
|
kill $BACKEND_PID $FRONTEND_PID 2>/dev/null
|
||||||
|
wait $BACKEND_PID $FRONTEND_PID 2>/dev/null
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap cleanup SIGINT SIGTERM
|
||||||
|
|
||||||
|
echo "Starting backend on http://localhost:8100 ..."
|
||||||
|
cd backend
|
||||||
|
.venv/Scripts/python -m uvicorn app.main:app --reload --port 8100 &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "Starting frontend on http://localhost:4200 ..."
|
||||||
|
cd frontend
|
||||||
|
npx ng serve &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "req-planner running:"
|
||||||
|
echo " Frontend: http://localhost:4200"
|
||||||
|
echo " Backend: http://localhost:8100"
|
||||||
|
echo " API docs: http://localhost:8100/docs"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop both services."
|
||||||
|
|
||||||
|
wait
|
||||||
185
seed_npc.py
Normal file
185
seed_npc.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""Seed the req-planner DB with neopixel-command-calculator requirements."""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
API = "http://localhost:8100/api"
|
||||||
|
|
||||||
|
def post(path, data):
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
req = urllib.request.Request(f"{API}/{path}", data=body, headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {TOKEN}",
|
||||||
|
})
|
||||||
|
resp = urllib.request.urlopen(req)
|
||||||
|
return json.loads(resp.read())
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
login = urllib.request.Request(f"{API}/auth/login",
|
||||||
|
data=json.dumps({"password": "what_is_this_2"}).encode(),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
TOKEN = json.loads(urllib.request.urlopen(login).read())["token"]
|
||||||
|
|
||||||
|
# Project
|
||||||
|
proj = post("projects", {
|
||||||
|
"name": "neopixel-command-calculator",
|
||||||
|
"description": "Engineering calculator for finding NeoPixel data transmission limits from single hardware sources. Web Components library (Lit + Vite) for CDN distribution.",
|
||||||
|
"repo_url": "https://theres-a-git-in-this-tea.andrewawesomo.net/andrewadmin/neopixel-command-calculator",
|
||||||
|
})
|
||||||
|
PID = proj["id"]
|
||||||
|
print(f"Project {PID}: {proj['name']}")
|
||||||
|
|
||||||
|
# Requirements data
|
||||||
|
reqs = [
|
||||||
|
# Root pillar
|
||||||
|
{"id": "NPC", "title": "NeoPixel Command Calculator",
|
||||||
|
"description": "A Web Components library providing engineering calculators for NeoPixel/addressable LED data transmission limits. Ships as CDN-ready ES+UMD bundles via Lit + Vite.",
|
||||||
|
"node_type": "pillar", "status": "draft", "priority": "critical", "project_id": PID,
|
||||||
|
"acceptance_criteria": "- Library loadable via <script> tag with zero dependencies\n- Three calculator components render and produce correct results\n- Themeable via CSS custom properties\n- Engine functions usable programmatically without UI"},
|
||||||
|
|
||||||
|
# Sub-pillars
|
||||||
|
{"id": "NPC-DATA", "title": "Protocol & Hardware Data Layer",
|
||||||
|
"description": "Type-safe data definitions for LED protocols and hardware profiles. Source of truth for all timing calculations.",
|
||||||
|
"node_type": "pillar", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC",
|
||||||
|
"acceptance_criteria": "- All 6 protocols defined with verified timing specs\n- ESP32-WROOM-32D profile with all RMT/SPI channels\n- Data validates internally (bitPeriodNs matches dataRateBps)\n- Easy to add new protocols and hardware profiles"},
|
||||||
|
|
||||||
|
{"id": "NPC-ENGINE", "title": "Calculation Engine",
|
||||||
|
"description": "Pure TypeScript functions for timing math, max LED calculation, refresh rate computation, and multi-strand optimization. No UI dependencies.",
|
||||||
|
"node_type": "pillar", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC",
|
||||||
|
"acceptance_criteria": "- All functions are pure (no side effects, no DOM)\n- Unit tested with known-good reference values\n- Handles edge cases (0 LEDs, impossible refresh rates)\n- Results match hand-calculated timing math"},
|
||||||
|
|
||||||
|
{"id": "NPC-UI", "title": "Web Components UI",
|
||||||
|
"description": "Three Lit-based Web Components that wrap the calculation engine with interactive controls and visualizations.",
|
||||||
|
"node_type": "pillar", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC",
|
||||||
|
"acceptance_criteria": "- Three components register as custom elements\n- All inputs reactive (changes recalculate immediately)\n- Themeable via --npc-* CSS custom properties\n- Accessible (ARIA labels, keyboard navigation)\n- Work in any framework or vanilla HTML"},
|
||||||
|
|
||||||
|
{"id": "NPC-BUILD", "title": "Build & Distribution",
|
||||||
|
"description": "Vite library mode build producing ES module and UMD bundles for CDN/script-tag consumption.",
|
||||||
|
"node_type": "pillar", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC",
|
||||||
|
"acceptance_criteria": "- ES and UMD bundles produced by npm run build\n- UMD bundle works via plain <script> tag\n- Lit bundled in (no peer dependency)\n- Bundle size reasonable (<100KB gzipped)"},
|
||||||
|
|
||||||
|
# DATA requirements
|
||||||
|
{"id": "NPC-DATA-PROTO", "title": "LED Protocol Definitions",
|
||||||
|
"description": "TypeScript interface and data for all supported LED protocols with timing specifications.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-DATA",
|
||||||
|
"acceptance_criteria": "- ProtocolTiming interface with: id, name, signalType (one-wire|spi), bitsPerLed, colorOrder, bitPeriodNs, resetTimeUs, dataRateBps\n- SPI overhead fields for APA102 (startFrameBits, endFrameBitsPerLed)\n- 6 protocols: WS2812B, SK6812 RGB, SK6812 RGBW, WS2811 800kHz, WS2811 400kHz, APA102\n- Exported lookup functions: getProtocol(id), getAllProtocols()"},
|
||||||
|
|
||||||
|
{"id": "NPC-DATA-HW", "title": "Hardware Profile Definitions",
|
||||||
|
"description": "TypeScript interface and data for microcontroller hardware profiles, starting with ESP32-WROOM-32D.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-DATA",
|
||||||
|
"acceptance_criteria": "- HardwareProfile interface with: id, name, channels[], dmaBufferBytes, cpuFreqMhz\n- OutputChannel interface with: id, type (rmt|spi|i2s), supportedSignalTypes, defaultGpio\n- ESP32-WROOM-32D: 8 RMT channels + 2 SPI buses with GPIO mappings\n- Exported lookup: getProfile(id), getAllProfiles()"},
|
||||||
|
|
||||||
|
# DATA leaves
|
||||||
|
{"id": "NPC-DATA-PROTO-WS", "title": "WS2812B / WS2811 Protocol Data",
|
||||||
|
"description": "Timing definitions for WS2812B (800kHz) and WS2811 (800kHz + 400kHz variants).",
|
||||||
|
"node_type": "leaf", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-DATA-PROTO",
|
||||||
|
"acceptance_criteria": "- WS2812B: 24 bits/LED, 1250ns bit period, 50us reset, 800kbps, one-wire, GRB\n- WS2811 800kHz: same timing as WS2812B\n- WS2811 400kHz: 24 bits/LED, 2500ns bit period, 50us reset, 400kbps\n- Values match official datasheets",
|
||||||
|
"code_language": "typescript", "target_impl_file": "src/data/protocols.ts"},
|
||||||
|
|
||||||
|
{"id": "NPC-DATA-PROTO-SK", "title": "SK6812 Protocol Data",
|
||||||
|
"description": "Timing definitions for SK6812 RGB and SK6812 RGBW variants.",
|
||||||
|
"node_type": "leaf", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-DATA-PROTO",
|
||||||
|
"acceptance_criteria": "- SK6812 RGB: 24 bits/LED, 1250ns bit period, 80us reset, 800kbps, one-wire, GRB\n- SK6812 RGBW: 32 bits/LED, 1250ns bit period, 80us reset, 800kbps, one-wire, GRBW\n- Note the longer reset time vs WS2812B (80us vs 50us)",
|
||||||
|
"code_language": "typescript", "target_impl_file": "src/data/protocols.ts"},
|
||||||
|
|
||||||
|
{"id": "NPC-DATA-PROTO-APA", "title": "APA102 Protocol Data",
|
||||||
|
"description": "SPI-based APA102 protocol with configurable clock and frame overhead.",
|
||||||
|
"node_type": "leaf", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-DATA-PROTO",
|
||||||
|
"acceptance_criteria": "- APA102: 32 bits/LED (8 global + 24 color), SPI signal type\n- Variable bit period (depends on SPI clock, default 8MHz = 125ns)\n- No reset time\n- SPI overhead: 32-bit start frame, ceil(N/2) end frame bits\n- dataRateBps configurable (not fixed like one-wire)",
|
||||||
|
"code_language": "typescript", "target_impl_file": "src/data/protocols.ts"},
|
||||||
|
|
||||||
|
{"id": "NPC-DATA-HW-ESP32", "title": "ESP32-WROOM-32D Profile",
|
||||||
|
"description": "Complete hardware profile for the ESP32-WROOM-32D development board.",
|
||||||
|
"node_type": "leaf", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-DATA-HW",
|
||||||
|
"acceptance_criteria": "- 8 RMT channels (RMT0-7): one-wire support, GPIOs 16,17,18,19,21,22,23,25\n- 2 SPI buses: HSPI (GPIO 13 MOSI), VSPI (GPIO 23 MOSI)\n- DMA buffer: 4096 bytes per channel\n- CPU freq: 240 MHz\n- Notes about RMT memory sharing (8 x 64 words)",
|
||||||
|
"code_language": "typescript", "target_impl_file": "src/data/hardware-profiles.ts"},
|
||||||
|
|
||||||
|
# ENGINE requirements
|
||||||
|
{"id": "NPC-ENG-TIMING", "title": "Core Timing Functions",
|
||||||
|
"description": "Pure functions for calculating data transmission time, frame time, and timing breakdowns for any protocol.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-ENGINE",
|
||||||
|
"acceptance_criteria": "- calcDataTimeUs(ledCount, protocol) -> microseconds of data transmission\n- calcFrameTimeUs(ledCount, protocol) -> data time + reset time\n- calcTimingBreakdown(ledCount, protocol, targetHz) -> {dataTimeUs, resetTimeUs, frameTimeUs, budgetTimeUs, idleTimeUs, utilizationPercent}\n- One-wire: dataTime = bitsPerLed * bitPeriodNs * ledCount / 1000\n- APA102: includes 32-bit start frame + ceil(N/2) end frame bits"},
|
||||||
|
|
||||||
|
{"id": "NPC-ENG-MAXLED", "title": "Max LED Calculator",
|
||||||
|
"description": "Calculate maximum LED count that fits within a target refresh rate budget.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-ENGINE",
|
||||||
|
"acceptance_criteria": "- calcMaxLeds(protocol, targetHz, maxBufferLeds?) -> number\n- Formula: floor((1M/targetHz - resetTimeUs) / (bitsPerLed * bitPeriodNs / 1000))\n- WS2812B @ 30Hz -> 1109 LEDs\n- SK6812 RGBW @ 60Hz -> 414 LEDs\n- Returns MaxLedsResult with limitingFactor (timing|buffer|protocol)"},
|
||||||
|
|
||||||
|
{"id": "NPC-ENG-REFRESH", "title": "Refresh Rate Calculator",
|
||||||
|
"description": "Calculate achievable refresh rate for a given LED count and protocol.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-ENGINE",
|
||||||
|
"acceptance_criteria": "- calcRefreshRate(ledCount, protocol) -> Hz\n- Formula: 1_000_000 / frameTimeUs\n- 300 WS2812B -> ~110.5 Hz\n- 144 WS2812B -> ~185 Hz\n- Returns RefreshRateResult with full timing breakdown"},
|
||||||
|
|
||||||
|
{"id": "NPC-ENG-STRAND", "title": "Multi-Strand Optimizer",
|
||||||
|
"description": "Distribute LEDs across available hardware channels to maximize aggregate refresh rate.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-ENGINE",
|
||||||
|
"acceptance_criteria": "- optimizeStrandDistribution(totalLeds, protocol, profile) -> StrandPlanResult\n- Filters channels by protocol signal type compatibility\n- Even distribution: ceil(total / channelCount) per strand\n- Identifies bottleneck strand (longest frame time)\n- Aggregate refresh = 1M / max(frameTimeUs)\n- 300 WS2812B on ESP32 (3 RMT) -> ~327 Hz"},
|
||||||
|
|
||||||
|
# ENGINE leaves
|
||||||
|
{"id": "NPC-ENG-TIMING-OW", "title": "One-Wire Timing Math",
|
||||||
|
"description": "Timing calculation for one-wire protocols (WS2812B, SK6812, WS2811).",
|
||||||
|
"node_type": "leaf", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-ENG-TIMING",
|
||||||
|
"acceptance_criteria": "- dataTimeUs = (bitsPerLed * bitPeriodNs * ledCount) / 1_000_000\n- frameTimeUs = dataTimeUs + resetTimeUs\n- WS2812B 100 LEDs: dataTime = 24*1250*100/1e6 = 3000us, frame = 3050us\n- SK6812W 100 LEDs: dataTime = 32*1250*100/1e6 = 4000us, frame = 4080us",
|
||||||
|
"code_language": "typescript", "target_impl_file": "src/engine/timing.ts", "target_test_file": "test/engine/timing.test.ts",
|
||||||
|
"test_spec": "GIVEN a one-wire protocol and LED count\nWHEN calcDataTimeUs is called\nTHEN it returns bitsPerLed * bitPeriodNs * ledCount / 1e6\nAND calcFrameTimeUs adds resetTimeUs"},
|
||||||
|
|
||||||
|
{"id": "NPC-ENG-TIMING-SPI", "title": "SPI Protocol Timing Math",
|
||||||
|
"description": "Timing calculation for SPI protocols (APA102) with frame overhead.",
|
||||||
|
"node_type": "leaf", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-ENG-TIMING",
|
||||||
|
"acceptance_criteria": "- totalBits = 32 + (32 * ledCount) + ceil(ledCount / 2)\n- dataTimeUs = totalBits * bitPeriodNs / 1_000_000\n- No reset time for APA102\n- At 8MHz: 100 LEDs -> totalBits = 32 + 3200 + 50 = 3282, dataTime = 410.25us",
|
||||||
|
"code_language": "typescript", "target_impl_file": "src/engine/timing.ts", "target_test_file": "test/engine/timing.test.ts",
|
||||||
|
"test_spec": "GIVEN APA102 protocol with SPI clock and LED count\nWHEN calcDataTimeUs is called\nTHEN it includes start frame (32 bits) + LED data + end frame (ceil(N/2) bits)"},
|
||||||
|
|
||||||
|
# UI requirements
|
||||||
|
{"id": "NPC-UI-SHARED", "title": "Shared UI Primitives",
|
||||||
|
"description": "Reusable sub-components and styles shared across all three calculators.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-UI",
|
||||||
|
"acceptance_criteria": "- <timing-bar> stacked horizontal bar: data (blue) | reset (yellow) | idle (green)\n- <math-breakdown> expandable section with formula steps [{label, formula, value}]\n- Shared CSS custom properties with --npc-* prefix and sensible defaults\n- format.ts: number formatting (commas, Hz, microseconds, percentage)"},
|
||||||
|
|
||||||
|
{"id": "NPC-UI-MAXLEDS", "title": "<neopixel-max-leds> Component",
|
||||||
|
"description": "Web Component calculator for maximum LED count given protocol, strands, refresh rate, and hardware.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-UI",
|
||||||
|
"acceptance_criteria": "- Properties: protocol (string), strands (number), refresh-rate (number), hardware (string)\n- Renders: dropdowns for protocol/hardware, number inputs for strands/refresh\n- Shows: max LEDs per strand, total LEDs, utilization bar\n- Expandable 'Show the math' with step-by-step formula\n- Recalculates on any input change"},
|
||||||
|
|
||||||
|
{"id": "NPC-UI-REFRESH", "title": "<neopixel-refresh-explorer> Component",
|
||||||
|
"description": "Web Component with interactive slider showing achievable refresh rates for different LED counts.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "critical", "project_id": PID, "parent_id": "NPC-UI",
|
||||||
|
"acceptance_criteria": "- Properties: led-count (number), protocol (string), hardware (string)\n- Range slider for LED count (1 to max) with real-time updates\n- Large refresh rate display (Hz)\n- Timing budget bar chart (data | reset | idle proportional segments)\n- Reference table: common LED counts and their refresh rates"},
|
||||||
|
|
||||||
|
{"id": "NPC-UI-PLANNER", "title": "<neopixel-strand-planner> Component",
|
||||||
|
"description": "Web Component for planning multi-strand LED configurations across hardware outputs.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-UI",
|
||||||
|
"acceptance_criteria": "- Properties: total-leds (number), protocol (string), hardware (string)\n- Strand assignment table: channel ID, type, GPIO, LED count, data time, refresh rate\n- Highlights bottleneck strand\n- Aggregate refresh rate prominently displayed\n- Input for total LED count with immediate recalculation"},
|
||||||
|
|
||||||
|
# BUILD requirements
|
||||||
|
{"id": "NPC-BLD-VITE", "title": "Vite Library Mode Config",
|
||||||
|
"description": "Configure Vite to produce both ES module and UMD bundles with Lit bundled in.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-BUILD",
|
||||||
|
"acceptance_criteria": "- vite.config.ts with lib entry, ES+UMD formats, es2021 target\n- Lit NOT externalized (bundled for CDN use)\n- Terser minification\n- Output: dist/neopixel-command-calculator.{es,umd}.js\n- package.json main/module/exports fields configured"},
|
||||||
|
|
||||||
|
{"id": "NPC-BLD-CDN", "title": "CDN/Script Tag Verification",
|
||||||
|
"description": "Verify the UMD bundle works when loaded via a plain <script> tag in a vanilla HTML page.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-BUILD",
|
||||||
|
"acceptance_criteria": "- Plain HTML file loads UMD bundle via <script src>\n- All three components render without errors\n- No console errors about missing dependencies\n- Components are interactive (inputs trigger recalculation)\n- demo/index.html serves as reference"},
|
||||||
|
|
||||||
|
{"id": "NPC-BLD-TEST", "title": "Test Infrastructure",
|
||||||
|
"description": "Vitest setup for unit testing the calculation engine with known reference values.",
|
||||||
|
"node_type": "requirement", "status": "draft", "priority": "high", "project_id": PID, "parent_id": "NPC-BUILD",
|
||||||
|
"acceptance_criteria": "- vitest.config.ts with node environment (pure function tests)\n- test/engine/timing.test.ts covering all timing functions\n- test/engine/strand-optimizer.test.ts covering distribution\n- Known reference values: WS2812B@30Hz=1109, SK6812W@60Hz=414\n- npm run test passes all tests"},
|
||||||
|
]
|
||||||
|
|
||||||
|
for r in reqs:
|
||||||
|
result = post("requirements", r)
|
||||||
|
print(f" [{r['node_type'][0].upper()}] {r['id']}: {r['title']}")
|
||||||
|
|
||||||
|
# Satisfaction links
|
||||||
|
links = [
|
||||||
|
("NPC-UI-MAXLEDS", "NPC-ENG-MAXLED"),
|
||||||
|
("NPC-UI-REFRESH", "NPC-ENG-REFRESH"),
|
||||||
|
("NPC-UI-PLANNER", "NPC-ENG-STRAND"),
|
||||||
|
("NPC-BLD-TEST", "NPC-ENGINE"),
|
||||||
|
]
|
||||||
|
for src, tgt in links:
|
||||||
|
post("links", {"source_id": src, "target_id": tgt, "relationship_type": "also_satisfies"})
|
||||||
|
print(f" Link: {src} -> {tgt}")
|
||||||
|
|
||||||
|
print(f"\nDone! {len(reqs)} requirements + {len(links)} satisfaction links created.")
|
||||||
126
update_esp32.py
Normal file
126
update_esp32.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""Update NPC-DATA-HW-ESP32 with sourced specs, test spec, impl code, and test code."""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
API = "http://localhost:8100/api"
|
||||||
|
|
||||||
|
# Auth
|
||||||
|
login = urllib.request.Request(f"{API}/auth/login",
|
||||||
|
data=json.dumps({"password": "what_is_this_2"}).encode(),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
TOKEN = json.loads(urllib.request.urlopen(login).read())["token"]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"acceptance_criteria": """- 8 RMT channels (0-7), each independently configurable as TX or RX
|
||||||
|
- RMT RAM: 512 x 32-bit words shared across all 8 channels
|
||||||
|
- Default: 1 memory block (64 x 32-bit words = 256 bytes) per channel
|
||||||
|
- Channel N can use up to (8-N) blocks (borrows from adjacent higher channels)
|
||||||
|
- NO DMA for RMT on original ESP32 — uses interrupt-driven successive copying
|
||||||
|
- 3 SPI peripherals: SPI (flash-only), HSPI (general), VSPI (general)
|
||||||
|
- HSPI default pins: MOSI=GPIO13, MISO=GPIO12, CLK=GPIO14, CS=GPIO15
|
||||||
|
- VSPI default pins: MOSI=GPIO23, MISO=GPIO19, CLK=GPIO18, CS=GPIO5
|
||||||
|
- GPIO 6-11: connected to internal flash, NOT usable
|
||||||
|
- GPIO 34-39: input-only, no pull-up/pull-down resistors
|
||||||
|
- Strapping pins: GPIO 0, 2, 4, 5, 12, 15 (affect boot mode)
|
||||||
|
- Safe output GPIOs for LED data: 2, 4, 5, 13, 14, 16-19, 21-23, 25-27, 32-33
|
||||||
|
- CPU frequency: 240 MHz (default), dual-core Xtensa LX6
|
||||||
|
- Flash: 4MB (WROOM-32D standard)
|
||||||
|
- SRAM: 520 KB""",
|
||||||
|
|
||||||
|
"test_spec": """GIVEN the ESP32-WROOM-32D hardware profile defined in code
|
||||||
|
WHEN each field is compared against the official Espressif documentation
|
||||||
|
THEN the following must match:
|
||||||
|
|
||||||
|
RMT Channels (source: ESP-IDF v4.4.1 RMT docs):
|
||||||
|
- channelCount equals 8
|
||||||
|
- each channel type is "rmt"
|
||||||
|
- each channel supportedSignalTypes includes "one-wire"
|
||||||
|
- no channel has dmaSupported set to true
|
||||||
|
|
||||||
|
SPI Buses (source: ESP32 TRM + GPIO reference):
|
||||||
|
- exactly 2 general-purpose SPI buses (HSPI, VSPI)
|
||||||
|
- HSPI defaultGpio is 13 (MOSI)
|
||||||
|
- VSPI defaultGpio is 23 (MOSI)
|
||||||
|
- each SPI channel supportedSignalTypes includes "spi"
|
||||||
|
|
||||||
|
GPIO Safety (source: ESP32-WROOM-32D datasheet):
|
||||||
|
- no channel defaultGpio is in [6,7,8,9,10,11] (flash pins)
|
||||||
|
- no channel defaultGpio is in [34,35,36,39] (input-only)
|
||||||
|
- all defaultGpio values are in the safe-output set
|
||||||
|
|
||||||
|
CPU/Memory (source: ESP32-WROOM-32D datasheet):
|
||||||
|
- cpuFreqMhz equals 240""",
|
||||||
|
|
||||||
|
"test_code": """/**
|
||||||
|
* Verification sources (datasheets/docs to compare against):
|
||||||
|
*
|
||||||
|
* 1. ESP-IDF v4.4.1 RMT Reference
|
||||||
|
* https://docs.espressif.com/projects/esp-idf/en/v4.4.1/esp32/api-reference/peripherals/rmt.html
|
||||||
|
* - 8 channels, 512x32-bit shared RAM, 64-word blocks
|
||||||
|
* - No DMA: "interrupt to successively copy new data chunks"
|
||||||
|
*
|
||||||
|
* 2. ESP32-WROOM-32D Datasheet
|
||||||
|
* https://www.espressif.com/sites/default/files/documentation/esp32-wroom-32d_esp32-wroom-32u_datasheet_en.pdf
|
||||||
|
* - 240MHz dual-core, 4MB flash, 520KB SRAM
|
||||||
|
* - GPIO 6-11 flash, GPIO 34-39 input-only
|
||||||
|
*
|
||||||
|
* 3. ESP32 GPIO Reference (RandomNerdTutorials)
|
||||||
|
* https://randomnerdtutorials.com/esp32-pinout-reference-gpios/
|
||||||
|
* - Strapping pins: 0, 2, 4, 5, 12, 15
|
||||||
|
* - VSPI: MOSI=23, MISO=19, CLK=18, CS=5
|
||||||
|
* - HSPI: MOSI=13, MISO=12, CLK=14, CS=15
|
||||||
|
* - Safe outputs: 2,4,5,13,14,16-19,21-23,25-27,32-33
|
||||||
|
*/""",
|
||||||
|
|
||||||
|
"impl_code": """// src/data/hardware-profiles.ts
|
||||||
|
|
||||||
|
const SAFE_OUTPUT_GPIOS = [2,4,5,13,14,16,17,18,19,21,22,23,25,26,27,32,33];
|
||||||
|
const FLASH_GPIOS = [6,7,8,9,10,11];
|
||||||
|
const INPUT_ONLY_GPIOS = [34,35,36,39];
|
||||||
|
const STRAPPING_GPIOS = [0,2,4,5,12,15];
|
||||||
|
|
||||||
|
export const ESP32_WROOM_32D: HardwareProfile = {
|
||||||
|
id: "esp32-wroom-32d",
|
||||||
|
name: "ESP32-WROOM-32D",
|
||||||
|
cpuFreqMhz: 240,
|
||||||
|
dmaBufferBytes: 0, // No DMA for RMT on original ESP32
|
||||||
|
rmtMemoryBlocks: 8, // 8 blocks x 64 words = 512 x 32-bit
|
||||||
|
rmtWordsPerBlock: 64,
|
||||||
|
channels: [
|
||||||
|
// 8 RMT channels for one-wire protocols
|
||||||
|
{ id: "RMT0", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 16, maxMemoryBlocks: 8 },
|
||||||
|
{ id: "RMT1", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 17, maxMemoryBlocks: 7 },
|
||||||
|
{ id: "RMT2", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 18, maxMemoryBlocks: 6 },
|
||||||
|
{ id: "RMT3", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 19, maxMemoryBlocks: 5 },
|
||||||
|
{ id: "RMT4", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 21, maxMemoryBlocks: 4 },
|
||||||
|
{ id: "RMT5", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 22, maxMemoryBlocks: 3 },
|
||||||
|
{ id: "RMT6", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 23, maxMemoryBlocks: 2 },
|
||||||
|
{ id: "RMT7", type: "rmt", supportedSignalTypes: ["one-wire"], defaultGpio: 25, maxMemoryBlocks: 1 },
|
||||||
|
// 2 SPI buses for clock-based protocols (APA102)
|
||||||
|
{ id: "HSPI", type: "spi", supportedSignalTypes: ["spi"], defaultGpio: 13 },
|
||||||
|
{ id: "VSPI", type: "spi", supportedSignalTypes: ["spi"], defaultGpio: 23 },
|
||||||
|
],
|
||||||
|
notes: "RMT uses interrupt-driven successive copying (no DMA). GPIO 6-11 reserved for flash. GPIO 34-39 input-only.",
|
||||||
|
};""",
|
||||||
|
|
||||||
|
"code_language": "typescript",
|
||||||
|
"target_impl_file": "src/data/hardware-profiles.ts",
|
||||||
|
"target_test_file": "test/data/hardware-profiles.test.ts",
|
||||||
|
}
|
||||||
|
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{API}/requirements/NPC-DATA-HW-ESP32",
|
||||||
|
data=body,
|
||||||
|
method="PUT",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {TOKEN}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = urllib.request.urlopen(req)
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
print(f"Updated: {result['id']} - {result['title']}")
|
||||||
|
print(f"Has test_spec: {bool(result.get('test_spec'))}")
|
||||||
|
print(f"Has impl_code: {bool(result.get('impl_code'))}")
|
||||||
|
print(f"Has test_code: {bool(result.get('test_code'))}")
|
||||||
Reference in New Issue
Block a user