diff --git a/.github/renovate.json b/.github/renovate.json deleted file mode 100644 index f5e599d2..00000000 --- a/.github/renovate.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:recommended"], - "schedule": ["before 3am on the first day of the month"], - "timezone": "Asia/Kolkata", - "labels": ["dependencies"], - "packageRules": [ - { - "matchUpdateTypes": ["minor", "patch"], - "groupName": "all minor and patch", - "groupSlug": "all-minor-patch", - "automerge": true - }, - { - "matchUpdateTypes": ["major"], - "groupName": "all major", - "groupSlug": "all-major" - } - ] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de65119f..fbaa313c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,7 @@ jobs: # fails the build on lint/type/test errors. backend: runs-on: ubuntu-latest + timeout-minutes: 15 defaults: run: working-directory: backend diff --git a/.github/workflows/deploy-frontend.yml b/.github/workflows/deploy-frontend.yml index 8fce2fc3..28fb84e0 100644 --- a/.github/workflows/deploy-frontend.yml +++ b/.github/workflows/deploy-frontend.yml @@ -12,6 +12,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: read defaults: @@ -66,6 +67,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + timeout-minutes: 10 needs: build steps: - name: Deploy to GitHub Pages diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml index 606faa9b..4a3262a0 100644 --- a/.github/workflows/keepalive.yml +++ b/.github/workflows/keepalive.yml @@ -22,11 +22,13 @@ permissions: jobs: ping: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Wake backend run: | - # The /api/health endpoint is unauthenticated and returns instantly. - # We don't fail the workflow on non-200 — the goal is only to keep + # The /health endpoint is unauthenticated and returns instantly. + # (App-level route in main.py -- no /api/ prefix, unlike domain routers.) + # We don't fail the workflow on non-200 -- the goal is only to keep # the branch warm; a transient 5xx during deploy is fine. curl --silent --max-time 30 \ "https://ledger-sync-api.vercel.app/health" \ diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 16b26495..9dca3c39 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -7,18 +7,20 @@ on: - 'backend/alembic/**' - 'backend/src/ledger_sync/db/migrations/**' - 'backend/src/ledger_sync/db/models.py' + - 'backend/src/ledger_sync/db/_models/**' workflow_dispatch: # Allow manual trigger jobs: migrate: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: - python-version: '3.14' + python-version: '3.13' - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 423d37e1..f53efdbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,41 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## Unreleased -- `feat/comprehensive-hardening` (PR #202) + +Comprehensive hardening wave landing on the branch: security tightening (BYOK key split + HKDF, refresh-token rotation, per-user rate limits, DB-level cascade), the anomaly-detection rewrite, the 50/30/20 Budget Rule page, and a design-system consistency pass that trims data past today across every historical chart and codifies the money-cell pattern. + +### Added + +- **50/30/20 Budget Rule page (`/budget`).** Replaces the old category-CRUD `/budgets`. Three side-by-side bucket cards (Needs / Wants / Savings) show target vs actual with a delta score; the breakdown table caps each bucket at top-10 categories with an "Other (N more)" rollup that expands inline. Period picker covers 1yr, 2yr, 5yr, All-time, Custom. Backend endpoint `POST /api/analytics/v2/spending-rule` classifies categories via word-boundary regex (fixes Education-not-in-Needs bug), unions user overrides with defaults (fixes first-override-drops-defaults bug), and relabels compound `Transfer: Bank: X -> Y: Z` rows to instrument names (Stocks, Mutual Funds, PPF, etc.). +- **`` primitive** (`frontend/src/components/ui/Money.tsx`). Codifies the canonical amount-cell rule set: `shrink-0 text-right tabular-nums whitespace-nowrap font-medium` plus `sm|md|lg|xl` width presets so flex parents can't compress the amount. Prevents the `₹12,91` truncation bug that occurred inside 33%-wide columns. Barrel-exported from `@/components/ui`; migrated `CategoryTable`, `CategoryBreakdown` (both top-level and subcategory rows). +- **`capEndDateAtToday()` and `capSeriesToToday()`** in `frontend/src/lib/dateUtils.ts`. Historical time-series charts across the app no longer render a flat-zero tail past today. `capEndDateAtToday()` is applied inside `getAnalyticsDateRange()` so it cascades through `useAnalyticsTimeFilter`, `useDashboardMetrics`, and every analytics query hook without per-caller edits. `capSeriesToToday(rows, key)` is generic on row shape with ISO-string comparison for use with in-memory month or day series. Projection pages (FIRE, tax-planning multi-year, retirement forecasting) intentionally build their own future ranges and are untouched. +- **Anomaly detection rewrite** (`api/analytics/anomalies.py`). Median + MAD (Median Absolute Deviation) with a rolling per-category baseline; robust to skew and single-transaction outliers. +- **DB-level `ON DELETE CASCADE`** on every `user_id` foreign key. Migration `20260704_1100_add_ondelete_cascade_user_fks.py` handles both PostgreSQL (`ALTER TABLE ... DROP CONSTRAINT / ADD CONSTRAINT`) and SQLite (`batch_alter_table(recreate="always", naming_convention=...)` with FK-name reflection to avoid the duplicate-FK trap that happens when `create_foreign_key` runs without an explicit `drop_constraint` in the same batch). + +### Changed + +- **`PageContainer` migration** for 5 pages (Anomaly, FIRE, Transactions, More, SubscriptionTracker). Hand-rolled `min-h-dvh p-4 md:p-6 lg:p-8` + `max-w-7xl mx-auto space-y-6` scaffolds replaced with the shared primitive. +- **Palette drift fixes.** `year-in-review/types.ts` heatmap band colors moved from slate-tinted rgba to `${rawColors.app.red}` template literals (theme-aware). New `rawColors.onAccent` token replaces raw `'#fff'` in `PeriodSelectors`. `CommandPalette` inline shadow moved to the shared `var(--glass-shadow-ultra)` token + app-blue ambient glow. +- **Year-in-review Monthly Breakdown** now slices `MONTHS_SHORT` at today's month for the current calendar/fiscal year (no more empty bars for future months). FY wrap handled via `((nowMonth - (fyStart - 1) + 12) % 12) + 1`. +- **CI on Python 3.13 everywhere**, added job timeouts. + +### Fixed + +- Cascade migration created duplicate FKs on SQLite (drop + create in same batch = REPLACE, but pure `create_foreign_key` alone APPENDS). +- Frontend crash `Cannot read properties of undefined (reading 'start')` on the budget page in demo mode -- demo axios adapter's catch-all `/analytics/v2/*` returned the wrong shape for spending-rule. +- SonarCloud MAJOR bug: identical ternary branches in a `capForBar` variable that always resolved to `target`. +- Substring false positives in investment-account classifier (`'rd'` matched inside `"weird broker xyz"`); replaced exact-match with word-boundary regex. +- Failed main deploy on 2026-07-03 (transient GitHub Pages API error); rerun succeeded. + +### Security + +- **Split BYOK encryption key from JWT secret.** Uses HKDF for v2 keys with per-ciphertext salt; JWT rotation no longer invalidates stored API keys. +- **Refresh-token rotation via `User.token_version`.** Logout / password reset increments the version claim; old refresh tokens fail verification server-side. +- **Per-authenticated-user rate limits** on `/api/upload` and `/api/ai/bedrock/chat` (was IP-keyed). + +--- + ## 2.19.0 - 2026-06-28 A premium **light theme**, a world-class UI/UX elevation pass, and an information-architecture restructure. Adds a Light/Dark/System toggle and makes the entire app render flawlessly in both themes, driven by live design-system research (Radix Colors, Material 3, Apple HIG), a 14-lens UI audit, and a 5-lens IA audit. Frontend-only -- no API, schema, or backend changes. Dark theme is preserved byte-for-byte. diff --git a/CLAUDE.md b/CLAUDE.md index f618dd10..c4bc08a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,7 +95,7 @@ Layered architecture: ### Frontend (`frontend/src/`) - **`pages/`** - 25 page components (24 feature pages + `MorePage` for phone grid nav), all lazy-loaded via `React.lazy` for code splitting. Pages import directly (no barrel re-export). **Structure convention**: multi-file pages use kebab-case directories (`bill-calendar/`, `year-in-review/`, `tax-planning/`, `trends-forecasts/`, `comparison/`, `goals/`, `income-expense-flow/`, `settings/`, `subscription-tracker/`) -- each containing `PageName.tsx` + `use.ts` + `types.ts` + `*utils.ts` + `components/` subfolder. Single-file pages use PascalCase (`DashboardPage.tsx`, `BudgetPage.tsx`, etc.). Settings uses `sections/` (instead of `components/`) because "section" is the domain term. `settings/sectionPrimitives.tsx` provides shared Section/FieldLabel/FieldHint primitives. See [`docs/HANDBOOK.md`](docs/HANDBOOK.md) for the full card-by-card walkthrough of every page + setting (formula + data source per metric), or [`docs/PAGES.md`](docs/PAGES.md) for the shorter data-focused catalog. -- **`components/`** - Organized by domain: `analytics/` (chart components including `CategoryBreakdown` for shared category treemaps), `chat/` (ChatWidget, ChatPanel, ChatMessage, useChat hook for AI chatbot), `layout/` (AppLayout, Sidebar with ProfileModal), `shared/` (reusable components like MetricCard, ProgressBar, Sparkline, AnalyticsTimeFilter, ProtectedRoute, ProfileModal, ChunkErrorBoundary, EmptyState, LoadingSkeleton, QuickInsights), `transactions/`, `upload/`, `ui/` (base primitives including ChartContainer, PageContainer, PageHeader, Spinner, DataTable, ConfirmDialog, CollapsibleSection, plus chartDefaults helpers `referenceLine`/`currencyTooltipFormatter`). +- **`components/`** - Organized by domain: `analytics/` (chart components including `CategoryBreakdown` for shared category treemaps), `chat/` (ChatWidget, ChatPanel, ChatMessage, useChat hook for AI chatbot), `layout/` (AppLayout, Sidebar with ProfileModal), `shared/` (reusable components like MetricCard, ProgressBar, Sparkline, AnalyticsTimeFilter, ProtectedRoute, ProfileModal, ChunkErrorBoundary, EmptyState, LoadingSkeleton, QuickInsights), `transactions/`, `upload/`, `ui/` (base primitives including ChartContainer, PageContainer, PageHeader, Spinner, DataTable, ConfirmDialog, CollapsibleSection, Money, plus chartDefaults helpers `referenceLine`/`currencyTooltipFormatter`). - **`hooks/`** - Custom React hooks. `useAnalyticsTimeFilter` encapsulates time-filter state (view mode, date range, FY) shared across all analytics pages. `useChartDimensions` provides responsive chart sizing. `hooks/api/` contains TanStack Query hooks for API calls, configured with `staleTime: Infinity` and `gcTime: 1 hour`. - **`services/api/`** - Axios-based API client. Axios interceptor auto-attaches JWT `Authorization` header. `aiConfig.ts` handles AI provider configuration CRUD. - **`store/`** - Zustand stores: `authStore` (JWT tokens with persist middleware), `accountStore`, `budgetStore`, `investmentAccountStore`, `preferencesStore`. @@ -211,7 +211,9 @@ Repo-specific skills live in [`.claude/skills//SKILL.md`](.claude/skills/) - **Page scaffold**: Wrap page bodies in `PageContainer` (`@/components/ui`) -- one centered `max-w-7xl` root with consistent padding + section spacing. Don't hand-roll `min-h-dvh p-4 ... max-w-7xl mx-auto` per page. - **Charts**: Wrap in `ChartContainer` (pass `ariaLabel` -- every chart needs an accessible name). Prefer the `Standard{Bar,Area,Pie}Chart` wrappers. Use `referenceLine()` (peak/avg/target/goal/zero) and `currencyTooltipFormatter()` from `chartDefaults` instead of hand-rolling reference lines / tooltip formatters. Colors from `constants/`. Respect the data-viz-fit rules: pie/donut only <=7 slices, radar only for fixed 0-100 multivariate profiles, bars for rankings, time-series default to per-period not cumulative. - **Layout**: `PageHeader` for titles, `MetricCard` for KPIs (use its `change`/`subtitle`/`trend`/`hero` props to make a number informative -- don't hand-roll delta badges or sparkline slots), `EmptyState` for no-data, `Spinner` (`@/components/ui`) for transient loads and the `LoadingSkeleton` family for page/chart/table skeletons, `ProgressBar` (`@/components/shared`) for any actual-vs-target metric (fill + optional target tick + bullet `bands`) -- don't hand-roll `h-2 rounded-full` bars. -- **Tables**: Use `DataTable` from `@/components/ui` for flat + sortable tables. Column-driven API (`DataTableColumn`), internal sort state, keyboard-accessible sort headers, optional row animation, `stickyHeader`, and `mobileCards` (below `sm`, rows render as stacked label/value cards via `mobilePrimary`/`mobileLabel` -- use for wide tables so they don't horizontal-scroll on phones). Don't hand-roll `` unless the shape is genuinely different (expandable groups, pivoted rows-as-columns); for those, drop low-priority columns on mobile (`hidden sm:table-cell`) or pin the label column. +- **Tables**: Use `DataTable` from `@/components/ui` for flat + sortable tables. Column-driven API (`DataTableColumn`), internal sort state, keyboard-accessible sort headers, optional row animation, `stickyHeader`, and `mobileCards` (below `sm`, rows render as stacked label/value cards via `mobilePrimary`/`mobileLabel` -- use for wide tables so they don't horizontal-scroll on phones). Don't hand-roll `
` unless the shape is genuinely different (expandable groups, pivoted rows-as-columns); for those, drop low-priority columns on mobile (`hidden sm:table-cell`) or pin the label column. **At narrow widths (3-column grids, expandable rows), DataTable's `
` truncates money -- render amount cells with `` instead, or hand-roll a flex row following the CategoryBreakdown pattern.** +- **Money**: Any amount rendered in a flex row or narrow table cell uses `` from `@/components/ui`. Signature: ``. Codifies `shrink-0 text-right tabular-nums whitespace-nowrap font-medium` so the digits never truncate (was the `₹12,91` bug in the 3-col /budget layout). Omit `width` for free-flow contexts (hero KPIs, tooltips). +- **Historical charts**: Time-series data must not extend past today. Rely on `getAnalyticsDateRange()` -- it now caps `end_date` at today for FY/yearly/monthly modes (via `capEndDateAtToday()` in `lib/dateUtils.ts`), so every analytics endpoint call inherits the fix. For pre-computed in-memory series, wrap with `capSeriesToToday(rows, key)`. Projection pages (FIRE, tax multi-year, retirement) build their own future ranges and are exempt. - **Dark-only (for now)**: Light theme planned (#79). Until then, don't add `dark:` prefixes or theme toggles. - **No inline styles** for layout -- use Tailwind classes. - **Mobile-first**: most users are on phones. KPI/stat grids should be `grid-cols-2` on phone (not single-column), gate changes behind `sm:`/`lg:`/`useIsMobile` so desktop never regresses, keep interactive controls >=44px, and verify at real mobile width (Playwright) -- never claim mobile-friendly on static reasoning. diff --git a/backend/Makefile b/backend/Makefile deleted file mode 100644 index 20321aff..00000000 --- a/backend/Makefile +++ /dev/null @@ -1,63 +0,0 @@ -.PHONY: help install test lint format type-check clean run init - -help: - @echo "ledger-sync - Development Commands" - @echo "" - @echo "Available commands:" - @echo " make install - Install dependencies with Poetry" - @echo " make test - Run tests with pytest" - @echo " make test-cov - Run tests with coverage report" - @echo " make lint - Run ruff linter" - @echo " make format - Format code with black" - @echo " make type-check - Run mypy type checker" - @echo " make quality - Run all quality checks (lint, format, type-check)" - @echo " make clean - Remove generated files" - @echo " make init - Initialize database" - @echo " make shell - Activate Poetry shell" - -install: - poetry install - -test: - poetry run pytest -v - -test-cov: - poetry run pytest --cov=src/ledger_sync --cov-report=html --cov-report=term-missing - -lint: - poetry run ruff check src/ tests/ - -format: - poetry run black src/ tests/ - -format-check: - poetry run black --check src/ tests/ - -type-check: - poetry run mypy src/ - -quality: lint format-check type-check - @echo "✓ All quality checks passed!" - -clean: - find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - find . -type d -name .pytest_cache -exec rm -rf {} + 2>/dev/null || true - find . -type d -name .mypy_cache -exec rm -rf {} + 2>/dev/null || true - find . -type d -name htmlcov -exec rm -rf {} + 2>/dev/null || true - find . -type f -name "*.pyc" -delete - find . -type f -name ".coverage" -delete - rm -f ledger_sync.db 2>/dev/null || true - -init: - poetry run ledger-sync init - -shell: - poetry shell - -# Development workflow -dev: install quality test - @echo "✓ Development setup complete!" - -# Pre-commit checks -pre-commit: format lint type-check test - @echo "✓ Pre-commit checks passed!" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index fe4ec242..7c15d7fb 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ version = "2.17.0" description = "Personal finance dashboard backend -- Excel import, financial analytics, multi-currency exchange rates" authors = [{ name = "Sagar Gupta" }] readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.13" dependencies = [ "sqlalchemy>=2.0.51", "alembic>=1.18.4", @@ -94,7 +94,7 @@ select = ["E", "F", "I", "N", "W", "UP"] "src/ledger_sync/core/analytics_engine.py" = ["PERF401"] [tool.mypy] -python_version = "3.12" +python_version = "3.13" strict = true warn_return_any = true warn_unused_configs = true diff --git a/backend/src/ledger_sync/api/ai_chat.py b/backend/src/ledger_sync/api/ai_chat.py index fa2dd657..a2ed0752 100644 --- a/backend/src/ledger_sync/api/ai_chat.py +++ b/backend/src/ledger_sync/api/ai_chat.py @@ -43,7 +43,7 @@ record_usage, ) from ledger_sync.api.deps import CurrentUser, DatabaseSession -from ledger_sync.api.rate_limit import limiter +from ledger_sync.api.rate_limit import limiter, user_limiter from ledger_sync.config.settings import settings from ledger_sync.db.models import UserPreferences @@ -188,7 +188,11 @@ def _from_bedrock_blocks(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: 503: {"description": "Bedrock is not configured on the server"}, }, ) -@limiter.limit("30/minute") +# Two-layer rate limit. Per-user (30/min) is the primary throttle; IP-keyed +# (60/min) protects the auth path against unauthenticated floods and gives a +# safety net when a token is missing/malformed. +@user_limiter.limit("30/minute") +@limiter.limit("60/minute") def bedrock_chat_proxy( request: Request, # slowapi requires a `request: Request` parameter # noqa: ARG001 current_user: CurrentUser, diff --git a/backend/src/ledger_sync/api/analytics_v2.py b/backend/src/ledger_sync/api/analytics_v2.py index 2f30a0f0..2bee60ab 100644 --- a/backend/src/ledger_sync/api/analytics_v2.py +++ b/backend/src/ledger_sync/api/analytics_v2.py @@ -16,6 +16,7 @@ from ledger_sync.api.analytics_v2_impl import ( networth_misc_router, recurring_router, + spending_rule_router, summaries_router, ) from ledger_sync.api.deps import CurrentUser @@ -27,6 +28,7 @@ # Mount the split sub-routers under the same prefix. router.include_router(summaries_router) router.include_router(recurring_router) +router.include_router(spending_rule_router) router.include_router(networth_misc_router) diff --git a/backend/src/ledger_sync/api/analytics_v2_impl/__init__.py b/backend/src/ledger_sync/api/analytics_v2_impl/__init__.py index 68b816a6..334861bd 100644 --- a/backend/src/ledger_sync/api/analytics_v2_impl/__init__.py +++ b/backend/src/ledger_sync/api/analytics_v2_impl/__init__.py @@ -2,6 +2,12 @@ from .networth_misc import router as networth_misc_router from .recurring import router as recurring_router +from .spending_rule import router as spending_rule_router from .summaries import router as summaries_router -__all__ = ["networth_misc_router", "recurring_router", "summaries_router"] +__all__ = [ + "networth_misc_router", + "recurring_router", + "spending_rule_router", + "summaries_router", +] diff --git a/backend/src/ledger_sync/api/analytics_v2_impl/spending_rule.py b/backend/src/ledger_sync/api/analytics_v2_impl/spending_rule.py new file mode 100644 index 00000000..06a6b1dc --- /dev/null +++ b/backend/src/ledger_sync/api/analytics_v2_impl/spending_rule.py @@ -0,0 +1,590 @@ +"""50/30/20 budget-rule aggregation endpoint. + +Returns per-category monthly averages classified into Needs / Wants / Savings +buckets for a user-selected date range, plus header totals and delta-vs-target +scoring. Reads live transactions rather than pre-aggregated rollups because the +bucket classification depends on the current preferences (essential_categories, +investment_account_mappings) and a rollup would drift if a user tunes those. + +Bucket rules: + +- **Savings**: income / expense / transfer where the target account matches a + known investment mapping (SIP, MF, PPF, EPF, NPS, Stocks, RD, FD when + contribution-tagged). Also transfers TO an investment account. +- **Needs**: expense categories that are either in the user's + ``essential_categories`` preference OR in the built-in Indian defaults + (Rent, Housing, EMI, Utilities, Groceries, Fuel, Transport, Insurance, + Healthcare, Education, Family Support, Internet, Phone). +- **Wants**: any expense that is not Needs and not Savings (Dining, + Entertainment, Shopping, Travel, Subscriptions, etc.). + +Income (all rows with type=Income and not from an investment-income category) +is the denominator for the "% of income" scoring. Savings = income - expenses +per the 50/30/20 rule's original framing (Elizabeth Warren, *All Your Worth*), +which treats "savings" as what's left over, not just what you explicitly +transferred to an investment account. The response returns both: + +- ``savings_amount``: literal (income - total_expenses) for the header card. +- ``savings_by_category``: category rows for the table -- what you *did* put + into investment vehicles (SIP, PPF, etc.) so the user can see the breakdown. + +These match your ask: header shows the Warren definition, table shows the +account-level breakdown. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import UTC, datetime +from decimal import Decimal +from typing import Annotated, Any + +from fastapi import APIRouter, Query +from sqlalchemy import and_, or_ + +from ledger_sync.api.deps import CurrentUser, DatabaseSession +from ledger_sync.db.models import ( + Transaction, + TransactionType, + UserPreferences, +) + +router = APIRouter() + + +# ─── opinionated Indian defaults ──────────────────────────────────────────── + +# When the user hasn't tuned `essential_categories`, we ship these as defaults +# for the Needs bucket. Match is case-insensitive and matches on either +# ``category`` or ``subcategory`` -- users label the same concept differently +# ("Rent" vs "Housing/Rent" vs "Home/Rent"). Better to over-match Needs than +# under-match, since the failure mode of a mis-classified expense in Needs +# instead of Wants is a slightly conservative budget score. +_DEFAULT_NEEDS: frozenset[str] = frozenset( + s.lower() + for s in ( + "rent", + "housing", + "home loan", + "home-loan", + "emi", + "utilities", + "electricity", + "water", + "gas", + "cooking gas", + "cylinder", + "groceries", + "grocery", + "food", + "food & dining", # user's example includes Food under Needs + "fuel", + "petrol", + "diesel", + "transport", + "transportation", + "commute", + "insurance", + "health insurance", + "life insurance", + "healthcare", + "medical", + "medicine", + "doctor", + "hospital", + "education", + "school fees", + "tuition", + "family support", + "family", + "parents", + "internet", + "broadband", + "phone", + "mobile", + "recharge", + ) +) + + +# Display-side rename for Savings-bucket rows whose category is a "Transfer:" +# bookkeeping label. Users think of these as investments, not transfers -- +# the money went somewhere. Ordered from most specific to most generic; the +# first pattern that matches the ``to_account`` wins. +# +# The DB row is untouched; this only affects what the /budgets page shows. +# Labels are short instrument names (Stocks, Mutual Funds, PPF) so the /budgets +# page rows fit inside the narrow side-by-side columns without truncation. +_MUTUAL_FUNDS_LABEL = "Mutual Funds" + +_TRANSFER_RELABEL_BY_ACCOUNT: tuple[tuple[str, str], ...] = ( + # Multi-word patterns first (more specific). + ("fd/bonds", "FD / Bonds"), + ("mutual funds", _MUTUAL_FUNDS_LABEL), + ("mutual fund", _MUTUAL_FUNDS_LABEL), + ("recurring deposit", "Recurring Deposit"), + ("fixed deposit", "Fixed Deposit"), + ("sukanya samriddhi", "Sukanya Samriddhi"), + # Single-word patterns (matched at word boundaries to avoid substring + # false positives like "rd" inside "weird" / "board"). + ("ppf", "PPF"), + ("epf", "EPF"), + ("nps", "NPS"), + ("ssy", "Sukanya Samriddhi"), + ("elss", "ELSS"), + ("mf", _MUTUAL_FUNDS_LABEL), + ("sip", "SIP"), + ("stocks", "Stocks"), + ("equity", "Stocks"), + ("shares", "Stocks"), + ("groww", _MUTUAL_FUNDS_LABEL), + ("zerodha", "Stocks"), + ("kite", "Stocks"), + ("upstox", "Stocks"), + ("kuvera", _MUTUAL_FUNDS_LABEL), + ("indmoney", "Stocks"), + ("coin", _MUTUAL_FUNDS_LABEL), + ("rd", "Recurring Deposit"), + ("fd", "Fixed Deposit"), +) + + +_GENERIC_TRANSFER_LABELS: frozenset[str] = frozenset( + s.lower() for s in ("transfer", "transfer out", "transfer to", "movement", "internal transfer") +) + + +def _is_transfer_category(cat_lower: str) -> bool: + """True if the category is a generic bookkeeping Transfer label. + + Covers both the plain single-word form ('transfer', 'transfer to') AND + the multi-part 'Transfer: ' pattern that ledger-sync's + default Excel template uses. Matching by prefix + colon avoids + accidentally catching a category literally named 'transferable' or a + user's actual investment category. + """ + if not cat_lower: + return True + if cat_lower in _GENERIC_TRANSFER_LABELS: + return True + # "transfer:" (colon suffix) or "transfer: " -- the ledger-sync + # Excel template writes rows as "Transfer: Bank: HDFC → Stocks: Groww". + return cat_lower.startswith("transfer:") + + +def _prettify_savings_label( + category: str, + subcategory: str | None, + to_account: str | None, +) -> tuple[str, str | None]: + """Return (category, subcategory) with generic 'Transfer' labels swapped + for the instrument name inferred from the destination account. + + Only fires for Savings-bucket rows; leaves everything else alone. Handles + both the plain 'Transfer' category AND the ledger-sync default template's + 'Transfer: ' compound form. + """ + cat_lower = (category or "").lower().strip() + if not _is_transfer_category(cat_lower) or not to_account: + return category, subcategory + + to_lower = to_account.lower() + for pattern, pretty in _TRANSFER_RELABEL_BY_ACCOUNT: + # Word-boundary match so 'rd' doesn't match 'weird broker' and + # 'mf' doesn't match 'management firm'. Multi-word patterns like + # 'mutual funds' fit \b...\b naturally on space boundaries. + if re.search(rf"\b{re.escape(pattern)}\b", to_lower): + # Keep the original subcategory only if it's not also a generic + # transfer label -- otherwise the row reads "PPF / Transfer" which + # is exactly what we're trying to fix. + sub_lower = (subcategory or "").lower().strip() + new_sub = None if _is_transfer_category(sub_lower) else subcategory + return pretty, new_sub + + # Transfer-flavored category but destination didn't match any known + # instrument (e.g. 'Cashback Shared', 'Security Deposits'). Return the raw + # category rather than a bogus label -- these get filtered out one step + # earlier by the wallet-to-wallet skip in practice; this is a safety net. + return category, subcategory + + +# Default set of investment-account patterns for the Savings bucket. Matched +# case-insensitively as substrings against the ``account`` / ``to_account`` +# field -- e.g. "Groww MF", "HDFC PPF Account", "NPS Tier 1" all match. +_DEFAULT_INVESTMENT_ACCOUNTS: frozenset[str] = frozenset( + s.lower() + for s in ( + "sip", + "mf", + "mutual fund", + "ppf", + "epf", + "nps", + "stocks", + "equity", + "shares", + "elss", + "recurring deposit", + "rd", + "sukanya samriddhi", + "ssy", + "groww", + "zerodha", + "kite", + "upstox", + "kuvera", + "coin", + ) +) + + +_TOP_SUBS_PER_ROW = 3 + + +@dataclass +class _CategoryRow: + category: str + bucket: str # "needs" | "wants" | "savings" + total_amount: Decimal + txn_count: int + months_seen: set[str] = field(default_factory=set) # YYYY-MM keys + # Subcategory rollup: {sub_label: total_amount}. Used to surface the + # top-3 subs inline under the category row on the /budgets page so a user + # with 7 Food & Dining subs still sees the breakdown without cluttering + # the primary table with 7 separate Food & Dining rows. + subs: dict[str, Decimal] = field(default_factory=dict) + + def add(self, amount: Decimal, subcategory: str | None, month_key: str) -> None: + self.total_amount += amount + self.txn_count += 1 + self.months_seen.add(month_key) + # NULL subcategory rolls up under a synthetic label so the top-subs + # array can still surface it (e.g. TRANSFER rows always have sub=NULL). + sub_key = subcategory or "(no subcategory)" + self.subs[sub_key] = self.subs.get(sub_key, Decimal(0)) + amount + + def to_dict(self, months_in_range: int) -> dict[str, Any]: + # Monthly average = total / months_in_period (not months-seen) -- + # otherwise a category with one December bill in a 12-month window + # looks like a huge monthly outflow. + avg_monthly = float(self.total_amount) / max(months_in_range, 1) + top_subs = sorted(self.subs.items(), key=lambda kv: kv[1], reverse=True)[:_TOP_SUBS_PER_ROW] + return { + "category": self.category, + # Kept for backward compatibility with the FE type; always None + # under the new grouping. The real per-sub detail lives in `top_subs`. + "subcategory": None, + "bucket": self.bucket, + "total_amount": float(self.total_amount), + "avg_monthly": avg_monthly, + "txn_count": self.txn_count, + "months_seen": len(self.months_seen), + "top_subs": [{"name": name, "amount": float(amount)} for name, amount in top_subs], + } + + +def _parse_json_pref(raw: str | None, fallback: Any) -> Any: + if not raw: + return fallback + try: + return json.loads(raw) + except (json.JSONDecodeError, TypeError): + return fallback + + +def _months_between(start: datetime, end: datetime) -> int: + """Inclusive month count between two dates, min 1.""" + months = (end.year - start.year) * 12 + (end.month - start.month) + 1 + return max(months, 1) + + +def _matches_investment_pattern(text_lower: str, patterns: set[str]) -> bool: + """True if any pattern appears at a word boundary in text_lower. + + Word-boundary matching stops short patterns like 'rd' / 'mf' from + accidentally matching inside 'weird broker' / 'wealth management fund'. + Multi-word patterns like 'recurring deposit' still match verbatim. + """ + for pattern in patterns: + # Escape + wrap in \b. re.search caches the compiled pattern under + # the hood; per-call cost is negligible given txn volumes. + if re.search(rf"\b{re.escape(pattern)}\b", text_lower): + return True + return False + + +def _classify_category( + category: str, + subcategory: str | None, + txn_type: TransactionType, + account: str, + to_account: str | None, + essential_set: set[str], + investment_accounts_set: set[str], +) -> str: + """Return one of 'needs', 'wants', 'savings'. + + Only called on expense-side rows for the Needs/Wants split. Savings + classification is done separately based on the destination account. + """ + cat_lower = (category or "").lower().strip() + sub_lower = (subcategory or "").lower().strip() + + # 1. Transfer to an investment account = savings, regardless of category. + if txn_type == TransactionType.TRANSFER and to_account: + if _matches_investment_pattern(to_account.lower(), investment_accounts_set): + return "savings" + + # 2. Expense on an investment account also counts as savings (SIP debit + # from bank account with account matching a broker/fund). + if txn_type == TransactionType.EXPENSE and account: + if _matches_investment_pattern(account.lower(), investment_accounts_set): + return "savings" + + # 3. Category-based needs classification. Word-boundary matching so a + # category like "Education & Learning" matches the singular default + # keyword "education" -- exact-string matching would miss compound labels + # ("Health & Insurance", "Home Loan / EMI", "Food & Dining") which is + # exactly the shape most Excel templates use. + if _matches_investment_pattern(cat_lower, essential_set): + return "needs" + if sub_lower and _matches_investment_pattern(sub_lower, essential_set): + return "needs" + + # 4. Everything else = wants (the residual bucket). + return "wants" + + +def _aggregate_txns( + txns: list[Transaction], + *, + essential_set: set[str], + investment_accounts_set: set[str], +) -> tuple[Decimal, Decimal, dict[str, Decimal], dict[tuple[str, str], _CategoryRow]]: + """Fold transactions into income/expense totals + per-bucket totals + category rows. + + Extracted from the endpoint handler to keep its cognitive complexity under + SonarCloud's threshold. See docstring on ``get_spending_rule_breakdown`` + for the semantics -- this is a pure aggregation over the pre-filtered rows. + """ + income_total = Decimal(0) + expense_total = Decimal(0) + bucket_totals: dict[str, Decimal] = { + "needs": Decimal(0), + "wants": Decimal(0), + "savings": Decimal(0), + } + # Group by (category, bucket). Subcategories roll up under their category + # row (see _CategoryRow.subs) so the /budgets page shows one row per + # category with top-3 subs inline -- else a user with 7 Food & Dining + # subs got 7 separate rows dominating Needs and drowning other categories. + category_rows: dict[tuple[str, str], _CategoryRow] = {} + + for t in txns: + amt = t.amount + month_key = t.date.strftime("%Y-%m") + + if t.type == TransactionType.INCOME: + income_total += amt + continue + + bucket = _classify_category( + category=t.category, + subcategory=t.subcategory, + txn_type=t.type, + account=t.account or "", + to_account=t.to_account, + essential_set=essential_set, + investment_accounts_set=investment_accounts_set, + ) + + # TRANSFER rows outside "savings" (wallet-to-wallet) are skipped -- + # they inflate both sides otherwise. + if t.type == TransactionType.TRANSFER and bucket != "savings": + continue + + if t.type == TransactionType.EXPENSE: + expense_total += amt + + bucket_totals[bucket] += amt + + display_category, display_sub = ( + _prettify_savings_label(t.category, t.subcategory, t.to_account) + if bucket == "savings" + else (t.category, t.subcategory) + ) + + key = (display_category, bucket) + row = category_rows.get(key) + if row is None: + row = _CategoryRow( + category=display_category, + bucket=bucket, + total_amount=Decimal(0), + txn_count=0, + ) + category_rows[key] = row + row.add(amt, display_sub, month_key) + + return income_total, expense_total, bucket_totals, category_rows + + +@router.get( + "/spending-rule", + responses={422: {"description": "Invalid date range"}}, +) +def get_spending_rule_breakdown( + current_user: CurrentUser, + db: DatabaseSession, + start_date: Annotated[ + datetime | None, + Query(description="Start of range (inclusive). Defaults to 12 months ago."), + ] = None, + end_date: Annotated[ + datetime | None, + Query(description="End of range (inclusive). Defaults to today."), + ] = None, +) -> dict[str, Any]: + """Return the 50/30/20 breakdown + per-category monthly averages. + + Response shape: + + { + "period": {"start": ISO, "end": ISO, "months": int}, + "income_total": float, + "expense_total": float, + "savings_amount": float, # income - expense (Warren definition) + "targets": {"needs": 50.0, "wants": 30.0, "savings": 20.0}, + "buckets": { + "needs": {"amount": float, "pct_of_income": float, "score_delta": float}, + "wants": {"amount": float, "pct_of_income": float, "score_delta": float}, + "savings": {"amount": float, "pct_of_income": float, "score_delta": float}, + }, + "categories": [ + {category, subcategory, bucket, total_amount, avg_monthly, txn_count, months_seen}, + ... + ] + } + + `score_delta` is the difference in percentage-points between actual and + target, signed so positive is "on the right side" for the bucket (under + for Needs/Wants, over for Savings). + """ + now = datetime.now(UTC) + end = end_date or now + start = start_date or end.replace(year=end.year - 1) + if start > end: + # Swap silently -- the frontend can send them either way. + start, end = end, start + months_in_range = _months_between(start, end) + + # Preferences -- use user overrides if set, else the opinionated defaults. + prefs: UserPreferences | None = ( + db.query(UserPreferences).filter(UserPreferences.user_id == current_user.id).one_or_none() + ) + user_essentials = _parse_json_pref(prefs.essential_categories if prefs else None, []) + user_inv_mappings = _parse_json_pref(prefs.investment_account_mappings if prefs else None, {}) + + # User overrides ADD to the built-in Indian defaults rather than + # replacing them. Previously an empty override reverted to defaults, + # but a user adding "Charity" would silently LOSE all defaults including + # Education / Housing / Groceries -- a nasty override-drops-defaults foot-gun. + essential_set: set[str] = set(_DEFAULT_NEEDS) | {s.lower() for s in user_essentials if s} + # investment_account_mappings is {"account_pattern": "type"} -- we only + # need the patterns. + investment_accounts_set: set[str] = {p.lower() for p in user_inv_mappings.keys() if p} or set( + _DEFAULT_INVESTMENT_ACCOUNTS + ) + + needs_target = prefs.needs_target_percent if prefs else 50.0 + wants_target = prefs.wants_target_percent if prefs else 30.0 + savings_target = prefs.savings_target_percent if prefs else 20.0 + + # ─── query ────────────────────────────────────────────────────────────── + # Pull every relevant txn in one shot. Volume is bounded by user history + + # date range; per-user datasets are small enough that a single scan is + # cheaper than three separate group-by queries. + txns = ( + db.query(Transaction) + .filter( + Transaction.user_id == current_user.id, + Transaction.is_deleted.is_(False), + Transaction.date >= start, + Transaction.date <= end, + or_( + Transaction.type == TransactionType.EXPENSE, + Transaction.type == TransactionType.INCOME, + and_( + Transaction.type == TransactionType.TRANSFER, + Transaction.to_account.isnot(None), + ), + ), + ) + .all() + ) + + income_total, expense_total, bucket_totals, category_rows = _aggregate_txns( + txns, + essential_set=essential_set, + investment_accounts_set=investment_accounts_set, + ) + + # Warren definition of savings for the header card. + savings_amount = income_total - expense_total + + # ─── shape response ───────────────────────────────────────────────────── + def _pct_of(x: Decimal) -> float: + if income_total <= 0: + return 0.0 + return float(x / income_total * 100) + + needs_pct = _pct_of(bucket_totals["needs"]) + wants_pct = _pct_of(bucket_totals["wants"]) + savings_pct = _pct_of(savings_amount) # Warren-style, not bucket_totals["savings"] + + # score_delta is signed so positive = on-the-good-side-of-target. + # For Needs/Wants (caps): positive = under target. + # For Savings (floor): positive = over target. + def _delta(actual: float, target: float, kind: str) -> float: + if kind == "cap": + return target - actual # under target -> positive + return actual - target # over floor -> positive + + return { + "period": { + "start": start.isoformat(), + "end": end.isoformat(), + "months": months_in_range, + }, + "income_total": float(income_total), + "expense_total": float(expense_total), + "savings_amount": float(savings_amount), + "targets": { + "needs": needs_target, + "wants": wants_target, + "savings": savings_target, + }, + "buckets": { + "needs": { + "amount": float(bucket_totals["needs"]), + "pct_of_income": needs_pct, + "score_delta": _delta(needs_pct, needs_target, "cap"), + }, + "wants": { + "amount": float(bucket_totals["wants"]), + "pct_of_income": wants_pct, + "score_delta": _delta(wants_pct, wants_target, "cap"), + }, + "savings": { + "amount": float(savings_amount), + "pct_of_income": savings_pct, + "score_delta": _delta(savings_pct, savings_target, "floor"), + }, + }, + "categories": sorted( + (row.to_dict(months_in_range) for row in category_rows.values()), + key=lambda r: (r["bucket"], -r["total_amount"]), + ), + } diff --git a/backend/src/ledger_sync/api/auth.py b/backend/src/ledger_sync/api/auth.py index aa7d0ff6..a4b23c53 100644 --- a/backend/src/ledger_sync/api/auth.py +++ b/backend/src/ledger_sync/api/auth.py @@ -63,18 +63,14 @@ def get_me(current_user: CurrentUser, auth_service: AuthServiceDep) -> UserRespo @router.post("/logout") -def logout(current_user: CurrentUser) -> MessageResponse: - """Logout current user. +def logout(current_user: CurrentUser, auth_service: AuthServiceDep) -> MessageResponse: + """Logout current user, invalidating all outstanding tokens server-side. - The frontend clears its stored tokens; we don't maintain a server-side - revocation list. The access token remains technically valid until its - natural expiry (30 min by default). The refresh token is single-use - in the sense that the frontend discards it on logout, but if it were - captured beforehand it would still work until expiry. - - Use account/reset or account delete to invalidate sessions in a way - that actually clears server-side state. + Bumps the user's ``token_version`` so both the access token in hand and + any leaked refresh tokens immediately fail JWT verification. The frontend + also clears its stored tokens on receipt of this response. """ + auth_service.logout(current_user) return MessageResponse(message="Successfully logged out") diff --git a/backend/src/ledger_sync/api/deps.py b/backend/src/ledger_sync/api/deps.py index 616443da..b359733e 100644 --- a/backend/src/ledger_sync/api/deps.py +++ b/backend/src/ledger_sync/api/deps.py @@ -46,6 +46,7 @@ def get_current_user( token = credentials.credentials + # First-pass decode to get user_id so we can look up the current token_version. token_data = verify_token(token, token_type="access") if token_data is None or token_data.user_id is None: raise credentials_exception @@ -55,6 +56,11 @@ def get_current_user( if user is None: raise credentials_exception + # Second-pass verification: reject tokens whose baked-in tv doesn't match the + # user's current token_version (bumped on logout / reset / delete). + if verify_token(token, token_type="access", expected_tv=user.token_version) is None: + raise credentials_exception + if not user.is_active: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/backend/src/ledger_sync/api/exchange_rates.py b/backend/src/ledger_sync/api/exchange_rates.py index a6b8dfcc..c4efc97d 100644 --- a/backend/src/ledger_sync/api/exchange_rates.py +++ b/backend/src/ledger_sync/api/exchange_rates.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -router = APIRouter(prefix="/exchange-rates", tags=["exchange-rates"]) +router = APIRouter(prefix="/api/exchange-rates", tags=["exchange-rates"]) _CACHE_TTL = 86400 # 24 hours in seconds _FRANKFURTER_URL = "https://api.frankfurter.dev/v1/latest" diff --git a/backend/src/ledger_sync/api/preferences_ai.py b/backend/src/ledger_sync/api/preferences_ai.py index 59c452fe..3fade410 100644 --- a/backend/src/ledger_sync/api/preferences_ai.py +++ b/backend/src/ledger_sync/api/preferences_ai.py @@ -199,9 +199,17 @@ def get_ai_key( if not prefs.ai_api_key_encrypted: raise HTTPException(status_code=404, detail="No AI key configured") try: - decrypted = decrypt_api_key(prefs.ai_api_key_encrypted) + decrypted, needs_reencrypt = decrypt_api_key(prefs.ai_api_key_encrypted) except DecryptionError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + + # In-band ciphertext upgrade: legacy v1 blobs get transparently rewritten + # as v2 (HKDF + separate encryption_key) on next read. See encryption.py + # docstring for the rollout plan. + if needs_reencrypt: + prefs.ai_api_key_encrypted = encrypt_api_key(decrypted) + session.commit() + response.headers["Cache-Control"] = "no-store, no-cache, private, max-age=0" response.headers["Pragma"] = "no-cache" return {"api_key": decrypted} diff --git a/backend/src/ledger_sync/api/rate_limit.py b/backend/src/ledger_sync/api/rate_limit.py index 867a78b6..2e486d88 100644 --- a/backend/src/ledger_sync/api/rate_limit.py +++ b/backend/src/ledger_sync/api/rate_limit.py @@ -1,14 +1,49 @@ -"""Shared rate limiter. - -A single Limiter instance is shared across all routers and attached to -``app.state.limiter`` in main.py. slowapi's ``_rate_limit_exceeded_handler`` -reads ``request.app.state.limiter`` to inject Retry-After / rate-limit headers; -if no limiter is attached it raises AttributeError and the 429 surfaces as a -generic 500. Sharing one instance keeps the decorator config and the handler -in sync. +"""Shared rate limiters (IP-keyed + user-keyed). + +Two Limiter instances are exported: + +- ``limiter`` — IP-keyed via ``get_remote_address``. Attached to + ``app.state.limiter`` in main.py so slowapi's + ``_rate_limit_exceeded_handler`` can read ``request.app.state.limiter`` + to inject Retry-After / rate-limit headers. Use for pre-auth endpoints + (OAuth callback, refresh) where the caller has no verified identity yet. + +- ``user_limiter`` — keyed on the JWT ``sub`` claim from the Authorization + header, falling back to remote address if the token is missing or invalid. + Use for authenticated endpoints where per-user limits are the correct + granularity (upload, AI chat). Behind CGNAT / carrier NAT, IP-keyed + limits bucket-share across every user on the same egress -- user-keyed + limits isolate per account. + +Decorators stack: ``@user_limiter.limit(...)`` alongside ``@limiter.limit(...)`` +evaluates both independently; whichever trips first returns 429. """ +from fastapi import Request from slowapi import Limiter from slowapi.util import get_remote_address +from ledger_sync.core.auth.tokens import decode_token + + +def _user_key_func(request: Request) -> str: + """Extract stable per-user identifier from Authorization header. + + Falls back to remote address when the token is missing / malformed / + expired so unauthenticated calls to authenticated endpoints still get + rate-limited (they'll 401 downstream, but the limiter also protects + the auth path). + """ + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + return get_remote_address(request) + + payload = decode_token(auth[7:]) + if payload is None or not payload.sub: + return get_remote_address(request) + + return f"user:{payload.sub}" + + limiter = Limiter(key_func=get_remote_address) +user_limiter = Limiter(key_func=_user_key_func) diff --git a/backend/src/ledger_sync/api/rates.py b/backend/src/ledger_sync/api/rates.py index 7770e041..c3ba0618 100644 --- a/backend/src/ledger_sync/api/rates.py +++ b/backend/src/ledger_sync/api/rates.py @@ -17,7 +17,7 @@ from ledger_sync.api.deps import CurrentUser -router = APIRouter(prefix="/rates", tags=["rates"]) +router = APIRouter(prefix="/api/rates", tags=["rates"]) _CONFIG_PATH = Path(__file__).resolve().parent.parent / "config" / "instrument_rates.json" diff --git a/backend/src/ledger_sync/api/stock_price.py b/backend/src/ledger_sync/api/stock_price.py index 44756049..e5812af1 100644 --- a/backend/src/ledger_sync/api/stock_price.py +++ b/backend/src/ledger_sync/api/stock_price.py @@ -10,7 +10,7 @@ from ledger_sync.api.deps import CurrentUser from ledger_sync.utils.logging import logger -router = APIRouter(prefix="/stock-price", tags=["stock-price"]) +router = APIRouter(prefix="/api/stock-price", tags=["stock-price"]) _YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" diff --git a/backend/src/ledger_sync/api/upload.py b/backend/src/ledger_sync/api/upload.py index 55606987..b13a8bab 100644 --- a/backend/src/ledger_sync/api/upload.py +++ b/backend/src/ledger_sync/api/upload.py @@ -13,7 +13,7 @@ from sqlalchemy.exc import OperationalError from ledger_sync.api.deps import CurrentUser, DatabaseSession -from ledger_sync.api.rate_limit import limiter +from ledger_sync.api.rate_limit import limiter, user_limiter from ledger_sync.core.analytics import AnalyticsEngine from ledger_sync.core.sync_engine import SyncEngine from ledger_sync.ingest.normalizer import NormalizationError @@ -23,10 +23,10 @@ router = APIRouter(prefix="", tags=["upload"]) -# Rate limit uploads. A single user doing normal statement imports won't -# come close to this; it exists to cap an abusive client that might try to -# replay / brute force uploads. Keyed by remote address, not user, so an -# unauthenticated flood is also throttled. +# Rate limit uploads. Two decorators stack -- whichever trips first returns +# 429. IP-keyed (5x higher) catches unauthenticated floods before they reach +# the auth dep; user-keyed protects each account behind CGNAT / carrier NAT +# where dozens of real users share one egress. @router.post( @@ -39,7 +39,8 @@ 500: {"description": "Processing failed"}, }, ) -@limiter.limit("10/minute") +@user_limiter.limit("10/minute") +@limiter.limit("50/minute") async def upload_transactions( request: Request, # required by slowapi # noqa: ARG001 payload: TransactionUploadRequest, diff --git a/backend/src/ledger_sync/config/settings.py b/backend/src/ledger_sync/config/settings.py index 7bf6be34..fbc53506 100644 --- a/backend/src/ledger_sync/config/settings.py +++ b/backend/src/ledger_sync/config/settings.py @@ -48,6 +48,20 @@ class Settings(BaseSettings): jwt_access_token_expire_minutes: int = 30 # 30 minutes (industry standard) jwt_refresh_token_expire_days: int = 7 + # At-rest encryption key for BYOK AI API keys. + # If unset, encryption.py falls back to jwt_secret_key (legacy behavior, deprecated). + # Splitting these lets us rotate the JWT secret without invalidating stored + # BYOK ciphertexts, and vice versa. Must be >= 32 chars in production. + encryption_key: str = "" + + # JWT strict token_version mode. + # During rollout, tokens issued before token_version was baked into JWTs + # still work (treated as tv=0). Flipping this to true on/after day 8 makes + # `verify_token` reject any token that lacks a `tv` claim. Refresh TTL is + # 7 days, so day 8 guarantees any surviving pre-migration refresh token + # is already expired. + jwt_strict_tv: bool = False + # Upload limits max_upload_size_bytes: int = MAX_UPLOAD_SIZE_BYTES @@ -143,6 +157,10 @@ def validate_production_settings(self) -> list[str]: if self.jwt_secret_key and len(self.jwt_secret_key) < 32: issues.append("CRITICAL: jwt_secret_key must be at least 32 characters") + # Encryption key length check (only enforce if user opted in by setting one) + if self.encryption_key and len(self.encryption_key) < 32: + issues.append("CRITICAL: encryption_key must be at least 32 characters") + if self.environment in ("staging", "production"): # SQLite not suitable for multi-user production if self.database_url.startswith("sqlite"): diff --git a/backend/src/ledger_sync/core/analytics/anomalies.py b/backend/src/ledger_sync/core/analytics/anomalies.py index 8963d727..33747d80 100644 --- a/backend/src/ledger_sync/core/analytics/anomalies.py +++ b/backend/src/ledger_sync/core/analytics/anomalies.py @@ -1,10 +1,47 @@ -"""Anomaly detection + budget tracking mixin.""" +"""Anomaly detection + budget tracking mixin. + +## Detection algorithms + +Uses robust statistics (median + MAD, with IQR fence fallback) instead of +mean + stdev, so a single outlier month doesn't self-mask by inflating both +the mean and the standard deviation. The historical mean+stdev approach +missed genuine anomalies exactly when they mattered most: one 3x-of-normal +month raised the sample mean by ~25% and stdev by ~50%, silently pushing +its own modified-Z score below the flagging cutoff. + +- **High-expense-month detection**: Iglewicz-Hoaglin modified Z-score + ``|0.6745 * (x - median) / MAD|`` with configurable cutoff (default 3.5, + the NIST-recommended outlier boundary). When MAD collapses to zero + (>=50% of months are identical, e.g. all-zero), fall back to Tukey's + upper IQR fence (Q3 + 1.5 * IQR). + +- **Large-transaction detection**: 12-month rolling per-category median. + Was previously comparing against the all-time category average, so a + legitimate big purchase 2 years ago poisoned the baseline forever -- + new normal-size transactions looked small and genuinely large ones got + flagged less severely. Rolling window + median is order-of-magnitude + more robust. + +## Threshold preservation across the algorithm swap + +The existing user preference ``anomaly_expense_threshold`` stored a stdev +multiplier (default 2.0). To avoid a semantic-drift incident on deploy +(where every user's stored threshold would suddenly mean something +completely different), the new code maps the stdev-multiplier space onto +the modified-Z cutoff space: + + effective_z_cutoff = 3.5 * (stored_threshold / 2.0) + +So the default 2.0 gives the recommended 3.5 modified-Z cutoff; a user +who tuned to 2.5 (stricter) gets 4.375; a user who tuned to 1.5 (looser) +gets 2.625. Same knob, better math underneath, no migration required. +""" from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from decimal import Decimal -from statistics import mean, stdev +from statistics import median, quantiles from typing import Any from sqlalchemy import delete, func @@ -19,6 +56,33 @@ TransactionType, ) +# Iglewicz-Hoaglin constant for modified Z-score: 0.6745 = Phi^-1(0.75), +# makes MAD an unbiased estimator of sigma for normally distributed data. +_MZ_CONSTANT = 0.6745 + +# NIST-recommended cutoff for the Iglewicz-Hoaglin modified Z-score. +# See NIST Handbook of Statistical Methods, section 1.3.5.17. +_DEFAULT_MODIFIED_Z_CUTOFF = 3.5 + +# The legacy stdev multiplier that produced the default behavior; we anchor +# the mapping stored_threshold=2.0 <-> modified_z=3.5 so a user who never +# tuned the setting sees behavior close to the audit-published intent. +_LEGACY_ANCHOR_STDEV = 2.0 + +# Rolling window for large-transaction category baseline. 12 months is the +# standard financial-time-series window; anything shorter loses seasonal +# smoothing, longer lets stale outliers linger in the baseline. +_LARGE_TXN_WINDOW = timedelta(days=365) + +# Minimum sample size for the rolling baseline. Below this we're comparing +# against noise, not a signal -- warmup returns no anomalies rather than +# false-positive spam. +_LARGE_TXN_MIN_HISTORY = 5 + +# Ratio thresholds for large-transaction severity grading. +_LARGE_TXN_HIGH_RATIO = 5.0 +_LARGE_TXN_FLAG_RATIO = 3.0 + class AnomaliesMixin(AnalyticsEngineBase): """Mixin: anomaly detection + monthly budget tracking.""" @@ -26,23 +90,27 @@ class AnomaliesMixin(AnalyticsEngineBase): def _detect_anomalies(self) -> int: """Detect anomalies in the data using configurable thresholds.""" anomalies_detected: list[dict[str, Any]] = [] - threshold_multiplier = self.anomaly_expense_threshold # From preferences - # 1. Detect high expense months (>threshold x std dev) - self._detect_high_expense_months(anomalies_detected, threshold_multiplier) + # Map the legacy stdev-multiplier preference to the modified-Z cutoff. + # See module docstring for the anchor rationale. + stored = self.anomaly_expense_threshold + z_cutoff = _DEFAULT_MODIFIED_Z_CUTOFF * (stored / _LEGACY_ANCHOR_STDEV) - # 2. Detect large individual transactions (>3x category average) + self._detect_high_expense_months(anomalies_detected, z_cutoff) self._detect_large_transactions(anomalies_detected) - # Delete old unreviewed anomalies for this user and insert new + # Delete old unreviewed anomalies for this user and insert new. Reviewed + # anomalies are preserved so users don't have to re-dismiss the same + # finding on every refresh. del_stmt = delete(Anomaly).where(Anomaly.is_reviewed.is_(False)) if self.user_id is not None: del_stmt = del_stmt.where(Anomaly.user_id == self.user_id) self.db.execute(del_stmt) - # Sort by deviation BEFORE the 50-row cap so the most severe anomalies - # survive -- the unsorted cap was silently dropping the biggest outliers - # (e.g. a 638%-over-average transaction) while keeping smaller ones. + # Sort by deviation_pct desc BEFORE the 50-row cap so the most severe + # anomalies survive. (Cross-type ranking is imperfect since deviation_pct + # has different natural scales for month totals vs single txns; the + # comment thread notes this and it's on the follow-up list.) anomalies_detected.sort(key=lambda a: a.get("deviation_pct") or 0, reverse=True) for anomaly_data in anomalies_detected[:50]: # Limit to 50 anomalies @@ -62,12 +130,33 @@ def _detect_anomalies(self) -> int: return len(anomalies_detected) + # ─── robust baseline helpers ─────────────────────────────────────────── + + @staticmethod + def _mad(values: list[float], baseline: float) -> float: + """Median Absolute Deviation from a given baseline (usually the median).""" + return median(abs(v - baseline) for v in values) + + @staticmethod + def _tukey_upper_fence(values: list[float], k: float = 1.5) -> float | None: + """Q3 + k * IQR. Returns None if there are too few values for Q1/Q3.""" + if len(values) < 4: # noqa: PLR2004 -- quantile needs >=4 samples + return None + q1, _q2, q3 = quantiles(values, n=4) + return q3 + k * (q3 - q1) + + # ─── high-expense-month detector ─────────────────────────────────────── + def _detect_high_expense_months( self, anomalies: list[dict[str, Any]], - threshold_multiplier: float, + z_cutoff: float, ) -> None: - """Append anomaly dicts for months with unusually high total expenses.""" + """Append anomaly dicts for months whose total expenses look unusual. + + Uses Iglewicz-Hoaglin modified Z-score with IQR fence fallback. + See module docstring for the algorithm rationale. + """ sym = self._currency_symbol user_id = self._require_user_id() period_col = fmt_year_month(Transaction.date) @@ -83,74 +172,124 @@ def _detect_high_expense_months( monthly_query = apply_excluded_accounts_filter(monthly_query, self.excluded_accounts) monthly_expenses = monthly_query.group_by(period_col).all() - if len(monthly_expenses) <= 3: + if len(monthly_expenses) <= 3: # noqa: PLR2004 -- documented warmup return - expense_values = [float(m.total) for m in monthly_expenses] - avg_expense = mean(expense_values) - std_expense = stdev(expense_values) if len(expense_values) > 1 else 0 + values = [float(m.total) for m in monthly_expenses] + med = median(values) + mad = self._mad(values, med) - # If average is zero (all-zero months), there's no meaningful "unusual" to flag - if avg_expense <= 0: + if med <= 0: + # All-zero (or worse) baseline -- nothing meaningful to flag. return + # Preferred path: modified Z-score. Falls back to IQR fence when MAD + # collapses to zero (many identical months). + use_iqr = mad == 0 + iqr_fence = self._tukey_upper_fence(values) if use_iqr else None + for month in monthly_expenses: - month_total = float(month.total) - if month_total > avg_expense + threshold_multiplier * std_expense: - anomalies.append( - { - "type": AnomalyType.HIGH_EXPENSE, - "severity": "high" if month_total > avg_expense * 2.5 else "medium", - "description": ( - f"Unusually high expenses in {month.period}: " - f"{sym}{month_total:,.0f} vs avg {sym}{avg_expense:,.0f}" - ), - "period_key": month.period, - "expected_value": Decimal(str(avg_expense)), - "actual_value": Decimal(str(month.total)), - "deviation_pct": ((month_total - avg_expense) / avg_expense) * 100, - }, - ) + total = float(month.total) + severity = self._grade_month(total, med, mad, z_cutoff, use_iqr, iqr_fence) + if severity is None: + continue + deviation_pct = ((total - med) / med) * 100 + anomalies.append( + { + "type": AnomalyType.HIGH_EXPENSE, + "severity": severity, + "description": ( + f"Unusually high expenses in {month.period}: " + f"{sym}{total:,.0f} vs median {sym}{med:,.0f}" + ), + "period_key": month.period, + "expected_value": Decimal(str(med)), + "actual_value": Decimal(str(month.total)), + "deviation_pct": deviation_pct, + }, + ) + + @staticmethod + def _grade_month( + total: float, + med: float, + mad: float, + z_cutoff: float, + use_iqr: bool, + iqr_fence: float | None, + ) -> str | None: + """Return "high" / "medium" if the month is anomalous, else None.""" + if use_iqr: + if iqr_fence is None or total <= iqr_fence: + return None + return "high" if total > med * 2.5 else "medium" # noqa: PLR2004 + m_z = _MZ_CONSTANT * (total - med) / mad + if m_z <= z_cutoff: + return None + # Grade by how far past the cutoff we are, not raw deviation. + return "high" if m_z >= z_cutoff * 1.5 else "medium" # noqa: PLR2004 + + # ─── large-transaction detector (rolling window) ─────────────────────── def _detect_large_transactions(self, anomalies: list[dict[str, Any]]) -> None: - """Append anomaly dicts for transactions >3x their category average.""" + """Append anomaly dicts for individual expenses that look large versus + their category's rolling-12-month baseline. + + Rolling window + median means a legitimate big purchase from 2 years + ago no longer poisons the baseline, and the txn under test is compared + against a leave-one-out median (excluding itself). + """ sym = self._currency_symbol - user_id = self._require_user_id() - cat_avg_query = ( - self.db.query(Transaction.category, func.avg(Transaction.amount).label("avg_amount")) - .filter(Transaction.user_id == user_id) - .filter(Transaction.is_deleted.is_(False)) + + expense_txns = ( + self._user_transaction_query() .filter(Transaction.type == TransactionType.EXPENSE) + .order_by(Transaction.date.asc()) + .all() ) - cat_avg_query = apply_excluded_accounts_filter(cat_avg_query, self.excluded_accounts) - category_avgs = cat_avg_query.group_by(Transaction.category).all() - category_avg_map = {c.category: float(c.avg_amount) for c in category_avgs} - large_txns = ( - self._user_transaction_query().filter(Transaction.type == TransactionType.EXPENSE).all() - ) + # Group amounts by category, retaining chronological order so the + # rolling window can prune old entries with an O(1) index cursor. + # amount_history[cat] = list of (date, amount) sorted ascending. + history: dict[str, list[tuple[datetime, float]]] = {} + for t in expense_txns: + history.setdefault(t.category, []).append((t.date, float(t.amount))) - for txn in large_txns: - cat_avg = category_avg_map.get(txn.category, 0) - if cat_avg > 0 and float(txn.amount) > cat_avg * 3: - # Grade severity by how far above the category average it is, - # instead of a flat "medium" -- a 5x+ outlier reads as high. - ratio = float(txn.amount) / cat_avg - severity = "high" if ratio >= 5 else "medium" - anomalies.append( - { - "type": AnomalyType.HIGH_EXPENSE, - "severity": severity, - "description": ( - f"Large {txn.category} expense: " - f"{sym}{float(txn.amount):,.0f} vs avg {sym}{cat_avg:,.0f}" - ), - "transaction_id": txn.transaction_id, - "expected_value": Decimal(str(cat_avg)), - "actual_value": Decimal(str(txn.amount)), - "deviation_pct": ((float(txn.amount) - cat_avg) / cat_avg) * 100, - }, - ) + for txn in expense_txns: + cat_history = history.get(txn.category, []) + # Rolling window: keep amounts strictly older than the txn under + # test AND within the last 12 months. Leave-one-out prevents the + # txn from being compared against a baseline it moved. + cutoff_start = txn.date - _LARGE_TXN_WINDOW + window = [amt for (date, amt) in cat_history if cutoff_start <= date < txn.date] + if len(window) < _LARGE_TXN_MIN_HISTORY: + continue # warmup: not enough history for a meaningful baseline + + baseline = median(window) + if baseline <= 0: + continue + + ratio = float(txn.amount) / baseline + if ratio < _LARGE_TXN_FLAG_RATIO: + continue + + severity = "high" if ratio >= _LARGE_TXN_HIGH_RATIO else "medium" + anomalies.append( + { + "type": AnomalyType.HIGH_EXPENSE, + "severity": severity, + "description": ( + f"Large {txn.category} expense: " + f"{sym}{float(txn.amount):,.0f} vs rolling median {sym}{baseline:,.0f}" + ), + "transaction_id": txn.transaction_id, + "expected_value": Decimal(str(baseline)), + "actual_value": Decimal(str(txn.amount)), + "deviation_pct": ((float(txn.amount) - baseline) / baseline) * 100, + }, + ) + + # ─── budget tracking (unchanged behavior; kept in this mixin) ───────── def _update_budget_tracking(self) -> int: """Update budget tracking with current month's spending.""" @@ -192,7 +331,7 @@ def _update_budget_tracking(self) -> int: budget.updated_at = now # Check for budget exceeded anomaly - if budget.current_month_pct > 100: + if budget.current_month_pct > 100: # noqa: PLR2004 anomaly = Anomaly( user_id=self.user_id, anomaly_type=AnomalyType.BUDGET_EXCEEDED, diff --git a/backend/src/ledger_sync/core/analytics/fy_summaries.py b/backend/src/ledger_sync/core/analytics/fy_summaries.py index 50d801b7..39f3b6fb 100644 --- a/backend/src/ledger_sync/core/analytics/fy_summaries.py +++ b/backend/src/ledger_sync/core/analytics/fy_summaries.py @@ -13,9 +13,14 @@ from ledger_sync.core.analytics.base import AnalyticsEngineBase from ledger_sync.db.models import FYSummary, Transaction, TransactionType -# Match "tax"/"taxes" as a whole word so notes like "Taxi" or "Syntax" don't -# get counted as tax paid. -_TAX_NOTE_RE = re.compile(r"\btax(es)?\b", re.IGNORECASE) +# Match Indian tax-related notes as whole words. Original regex was `\btax(es)?\b` +# which missed the actual tax vocabulary users write in bank statements: GST, +# TDS, cess, surcharge, and "advance tax" / "self assessment" (which can appear +# with a space or hyphen). Word boundaries still keep "Taxi" and "Syntax" out. +_TAX_NOTE_RE = re.compile( + r"\b(tax(es)?|tds|gst|cess|surcharge|advance[\s-]?tax|self[\s-]?assessment)\b", + re.IGNORECASE, +) class FYSummariesMixin(AnalyticsEngineBase): diff --git a/backend/src/ledger_sync/core/auth/tokens.py b/backend/src/ledger_sync/core/auth/tokens.py index 86a34d76..202ec313 100644 --- a/backend/src/ledger_sync/core/auth/tokens.py +++ b/backend/src/ledger_sync/core/auth/tokens.py @@ -2,6 +2,18 @@ Provides functions for creating, decoding, and verifying JWT tokens. Uses PyJWT (actively maintained, no vulnerable ecdsa dependency). + +## Session revocation via token_version + +Every token embeds a ``tv`` (token_version) claim mirroring +``User.token_version``. Bumping the DB column on logout / account reset / +delete invalidates all outstanding tokens for that user in one write -- +no per-token blocklist required. + +During the rollout window, tokens issued by the pre-2026-07 code path +have no ``tv`` claim. ``verify_token`` treats missing ``tv`` as 0 unless +``settings.jwt_strict_tv`` is true. Flip that flag on day 8 (refresh TTL += 7 days + 1 buffer) to hard-cutoff legacy tokens. """ from datetime import UTC, datetime, timedelta @@ -14,16 +26,7 @@ def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: - """Create a JWT access token. - - Args: - data: Data to encode in the token (should include 'sub' and 'email') - expires_delta: Optional custom expiration time - - Returns: - Encoded JWT access token string - - """ + """Create a JWT access token.""" to_encode = data.copy() if expires_delta: expire = datetime.now(UTC) + expires_delta @@ -35,16 +38,7 @@ def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = def create_refresh_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: - """Create a JWT refresh token. - - Args: - data: Data to encode in the token (should include 'sub' and 'email') - expires_delta: Optional custom expiration time - - Returns: - Encoded JWT refresh token string - - """ + """Create a JWT refresh token.""" to_encode = data.copy() if expires_delta: expire = datetime.now(UTC) + expires_delta @@ -55,33 +49,26 @@ def create_refresh_token(data: dict[str, Any], expires_delta: timedelta | None = return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm) -def create_tokens(user_id: int, email: str) -> Token: +def create_tokens(user_id: int, email: str, token_version: int = 0) -> Token: """Create both access and refresh tokens for a user. Args: - user_id: User's database ID - email: User's email address + user_id: User's database ID. + email: User's email address. + token_version: Current ``User.token_version`` value, baked into ``tv`` + claim so a subsequent bump invalidates this token pair. Returns: - Token object containing access_token, refresh_token, and token_type - + Token object containing access_token, refresh_token, and token_type. """ - token_data = {"sub": str(user_id), "email": email} + token_data = {"sub": str(user_id), "email": email, "tv": token_version} access_token = create_access_token(token_data) refresh_token = create_refresh_token(token_data) return Token(access_token=access_token, refresh_token=refresh_token) def decode_token(token: str) -> TokenPayload | None: - """Decode and validate a JWT token. - - Args: - token: JWT token string to decode - - Returns: - TokenPayload if valid, None if invalid or expired - - """ + """Decode and validate a JWT token.""" try: payload: dict[str, Any] = jwt.decode( token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm] @@ -91,32 +78,53 @@ def decode_token(token: str) -> TokenPayload | None: email=payload.get("email", ""), exp=datetime.fromtimestamp(payload.get("exp", 0), tz=UTC), type=payload.get("type", "access"), + tv=payload.get("tv"), # None on legacy pre-tv tokens; enforced below ) except jwt.PyJWTError: return None -def verify_token(token: str, token_type: str = "access") -> TokenData | None: +def verify_token( + token: str, + token_type: str = "access", + expected_tv: int | None = None, +) -> TokenData | None: """Verify a JWT token and extract user data. Args: - token: JWT token string to verify - token_type: Expected token type ("access" or "refresh") + token: JWT token string to verify. + token_type: Expected token type ("access" or "refresh"). + expected_tv: Current ``User.token_version`` for the token's subject. + When provided, the token's ``tv`` claim must match. Pre-migration + tokens with no ``tv`` claim are accepted as ``tv=0`` unless + ``settings.jwt_strict_tv`` is true. Returns: - TokenData with user_id and email if valid, None otherwise - + TokenData with user_id and email if valid, None otherwise. """ payload = decode_token(token) if payload is None: return None - # Verify token type matches expected if payload.type != token_type: return None - # Verify token hasn't expired if payload.exp < datetime.now(UTC): return None - return TokenData(user_id=int(payload.sub), email=payload.email) + if expected_tv is not None: + token_tv = payload.tv + if token_tv is None: + # Legacy token from before token_version rolled out. + if settings.jwt_strict_tv: + return None + token_tv = 0 + if token_tv != expected_tv: + return None + + try: + user_id = int(payload.sub) + except (TypeError, ValueError): + return None + + return TokenData(user_id=user_id, email=payload.email) diff --git a/backend/src/ledger_sync/core/encryption.py b/backend/src/ledger_sync/core/encryption.py index ef5a069e..e0be7980 100644 --- a/backend/src/ledger_sync/core/encryption.py +++ b/backend/src/ledger_sync/core/encryption.py @@ -1,63 +1,182 @@ -"""AES-256-GCM encryption for API key storage. +"""AES-256-GCM encryption for at-rest secret storage (BYOK API keys). -Uses PBKDF2-HMAC-SHA256 to derive an encryption key from the application's -JWT secret, combined with a per-ciphertext random salt. The salt is stored -alongside the nonce and ciphertext so decryption can recover the same key. +## Two ciphertext formats coexist during rollout -Output format (base64-encoded): salt(16) || nonce(12) || ciphertext +- **v1 (legacy)**: `base64(salt(16) || nonce(12) || ciphertext)` + KDF is PBKDF2-HMAC-SHA256 (100k iterations) over `settings.jwt_secret_key`. + Written by the pre-2026-07 code path. + +- **v2 (current)**: `base64(0x02 || salt(16) || nonce(12) || ciphertext)` + KDF is HKDF-SHA256 over `settings.encryption_key` (fallback: jwt_secret_key). + HKDF is the correct primitive here -- the input material is already a + high-entropy server secret (KEK), not a low-entropy user password, so the + iteration-count knob PBKDF2 exists for is doing nothing useful. + +Decryption tries v2 first (by prefix byte), then legacy. Every legacy read +returns `needs_reencrypt=True` so the caller can transparently upgrade the +stored ciphertext -- see `preferences_ai.get_ai_key` for the pattern. + +## Why the key material is separate + +Right now `jwt_secret_key` triple-duties as JWT signer, OAuth state HMAC key, +and BYOK KEK. Rotating the JWT secret for token security reasons would silently +invalidate every stored BYOK key (DecryptionError). Splitting via a dedicated +`encryption_key` decouples those two rotation policies. + +Rollout: deploy with `LEDGER_SYNC_ENCRYPTION_KEY` set on Vercel; existing v1 +ciphertexts decrypt on next reveal and are re-written as v2 in-band. After +30 days of clean access logs (no v1 reads), the legacy branch can be removed. """ from __future__ import annotations import base64 +import logging import os from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from ledger_sync.config.settings import settings +logger = logging.getLogger(__name__) + _SALT_LENGTH = 16 # 128-bit random salt per ciphertext -_ITERATIONS = 100_000 +_NONCE_LENGTH = 12 # 96-bit nonce for GCM (AESGCM recommends 12 bytes) _KEY_LENGTH = 32 # AES-256 -_NONCE_LENGTH = 12 # 96-bit nonce for GCM +_LEGACY_ITERATIONS = 100_000 # v1 only; v2 uses HKDF which needs no iteration count +_V2_PREFIX = b"\x02" +_V2_HKDF_INFO = b"ledger-sync/byok-api-key/v2" + + +class DecryptionError(Exception): + """Raised when decryption fails (e.g. key changed between restarts).""" -def _derive_key(salt: bytes) -> bytes: - kdf = PBKDF2HMAC( +def _kek_material() -> bytes: + """Return the KEK bytes for v2 encryption. + + Prefer the dedicated encryption_key; fall back to jwt_secret_key with a + warning (deprecated during rollout, removed once all v1 ciphertexts have + been re-wrapped as v2). + """ + if settings.encryption_key: + return settings.encryption_key.encode() + if settings.jwt_secret_key: + logger.warning( + "LEDGER_SYNC_ENCRYPTION_KEY is not set; falling back to jwt_secret_key " + "as the BYOK KEK. Set a dedicated encryption key to decouple JWT rotation " + "from stored API keys." + ) + return settings.jwt_secret_key.encode() + # Both empty -- only reachable in a broken dev environment. + msg = "No key material configured (neither encryption_key nor jwt_secret_key set)" + raise DecryptionError(msg) + + +def _derive_key_v2(salt: bytes, material: bytes) -> bytes: + """HKDF-SHA256 key derivation for v2 ciphertexts. + + HKDF (RFC 5869) is the correct primitive when the input material is + already a strong key. Info parameter provides domain separation so the + same secret could safely derive keys for other purposes. + """ + return HKDF( + algorithm=hashes.SHA256(), + length=_KEY_LENGTH, + salt=salt, + info=_V2_HKDF_INFO, + ).derive(material) + + +def _derive_key_v1(salt: bytes, material: bytes) -> bytes: + """Legacy PBKDF2-HMAC-SHA256 for v1 ciphertexts. Read-only path.""" + return PBKDF2HMAC( algorithm=hashes.SHA256(), length=_KEY_LENGTH, salt=salt, - iterations=_ITERATIONS, - ) - return kdf.derive(settings.jwt_secret_key.encode()) + iterations=_LEGACY_ITERATIONS, + ).derive(material) def encrypt_api_key(plaintext: str) -> str: + """Encrypt with the current (v2) format. New writes always use v2.""" salt = os.urandom(_SALT_LENGTH) nonce = os.urandom(_NONCE_LENGTH) - key = _derive_key(salt) - aesgcm = AESGCM(key) - ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None) - return base64.b64encode(salt + nonce + ciphertext).decode() + key = _derive_key_v2(salt, _kek_material()) + ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode(), None) + return base64.b64encode(_V2_PREFIX + salt + nonce + ciphertext).decode() -class DecryptionError(Exception): - """Raised when decryption fails (e.g. key changed between restarts).""" +def decrypt_api_key(encrypted: str) -> tuple[str, bool]: + """Decrypt a v1 or v2 ciphertext. + Returns: + (plaintext, needs_reencrypt). If `needs_reencrypt` is True, the caller + should re-write the stored ciphertext with a fresh `encrypt_api_key` + call so v1 blobs get transparently upgraded. -def decrypt_api_key(encrypted: str) -> str: - raw = base64.b64decode(encrypted) - salt = raw[:_SALT_LENGTH] - nonce = raw[_SALT_LENGTH : _SALT_LENGTH + _NONCE_LENGTH] - ciphertext = raw[_SALT_LENGTH + _NONCE_LENGTH :] - key = _derive_key(salt) - aesgcm = AESGCM(key) + Raises: + DecryptionError: if the ciphertext is malformed or the key is wrong. + """ + try: + raw = base64.b64decode(encrypted) + except Exception as exc: + raise DecryptionError("API key ciphertext is not valid base64") from exc + + if raw[:1] == _V2_PREFIX: + return _decrypt_v2(raw[1:]), False + + return _decrypt_v1(raw), True # legacy blob -> upgrade on next write + + +def _decrypt_v2(body: bytes) -> str: + if len(body) < _SALT_LENGTH + _NONCE_LENGTH: + raise DecryptionError("v2 ciphertext is truncated") + salt = body[:_SALT_LENGTH] + nonce = body[_SALT_LENGTH : _SALT_LENGTH + _NONCE_LENGTH] + ciphertext = body[_SALT_LENGTH + _NONCE_LENGTH :] + key = _derive_key_v2(salt, _kek_material()) try: - return aesgcm.decrypt(nonce, ciphertext, None).decode() + return AESGCM(key).decrypt(nonce, ciphertext, None).decode() except Exception as exc: raise DecryptionError( - "Cannot decrypt API key -- the server secret likely changed since the key " - "was saved. Please re-enter your API key in Settings." + "Cannot decrypt v2 API key -- the encryption key likely changed since " + "the key was saved. Please re-enter your API key in Settings." ) from exc + + +def _decrypt_v1(raw: bytes) -> str: + """Legacy PBKDF2 path. Tries encryption_key first (if set) then jwt_secret_key. + + Tries both because during the rollout window, users may have set + `encryption_key` to the same value as `jwt_secret_key` before we deprecate + the fallback -- we still need to decrypt old ciphertexts that were written + when only `jwt_secret_key` existed. + """ + if len(raw) < _SALT_LENGTH + _NONCE_LENGTH: + raise DecryptionError("v1 ciphertext is truncated") + salt = raw[:_SALT_LENGTH] + nonce = raw[_SALT_LENGTH : _SALT_LENGTH + _NONCE_LENGTH] + ciphertext = raw[_SALT_LENGTH + _NONCE_LENGTH :] + + candidates: list[bytes] = [] + if settings.encryption_key: + candidates.append(settings.encryption_key.encode()) + if settings.jwt_secret_key: + candidates.append(settings.jwt_secret_key.encode()) + + last_error: Exception | None = None + for material in candidates: + try: + key = _derive_key_v1(salt, material) + return AESGCM(key).decrypt(nonce, ciphertext, None).decode() + except Exception as exc: # noqa: BLE001 -- try next material + last_error = exc + + raise DecryptionError( + "Cannot decrypt legacy (v1) API key -- neither the encryption key nor " + "the JWT secret unlocks it. Please re-enter your API key in Settings." + ) from last_error diff --git a/backend/src/ledger_sync/db/_models/ai_usage.py b/backend/src/ledger_sync/db/_models/ai_usage.py index 6baf1dba..6c66d6ac 100644 --- a/backend/src/ledger_sync/db/_models/ai_usage.py +++ b/backend/src/ledger_sync/db/_models/ai_usage.py @@ -27,7 +27,9 @@ class AIUsageLog(Base): __tablename__ = "ai_usage_log" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # When the call completed (server clock; self-reported calls use this too) timestamp: Mapped[datetime] = mapped_column( diff --git a/backend/src/ledger_sync/db/_models/analytics.py b/backend/src/ledger_sync/db/_models/analytics.py index 55037517..7c7296b2 100644 --- a/backend/src/ledger_sync/db/_models/analytics.py +++ b/backend/src/ledger_sync/db/_models/analytics.py @@ -38,7 +38,9 @@ class DailySummary(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes summary to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Date identification date: Mapped[str] = mapped_column(String(10), nullable=False) # YYYY-MM-DD @@ -71,7 +73,9 @@ class MonthlySummary(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes summary to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Period identification year: Mapped[int] = mapped_column(Integer, nullable=False) @@ -133,7 +137,9 @@ class CategoryTrend(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes trend to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Period and category period_key: Mapped[str] = mapped_column(String(7), nullable=False, index=True) # YYYY-MM @@ -172,7 +178,9 @@ class TransferFlow(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes flow to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Flow identification from_account: Mapped[str] = mapped_column(String(255), nullable=False, index=True) @@ -209,7 +217,9 @@ class MerchantIntelligence(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes merchant data to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Merchant identification (extracted from notes) merchant_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) @@ -247,7 +257,9 @@ class FYSummary(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes FY summary to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # FY identification (e.g., "FY2024-25" for Apr 2024 - Mar 2025) fiscal_year: Mapped[str] = mapped_column(String(15), nullable=False, index=True) @@ -307,7 +319,9 @@ class CohortSpending(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # 'day_of_week' | 'day_of_month' | 'month_of_year' dimension: Mapped[str] = mapped_column(String(20), nullable=False) diff --git a/backend/src/ledger_sync/db/_models/investments.py b/backend/src/ledger_sync/db/_models/investments.py index 0cf95cee..bce00e00 100644 --- a/backend/src/ledger_sync/db/_models/investments.py +++ b/backend/src/ledger_sync/db/_models/investments.py @@ -30,7 +30,9 @@ class TaxRecord(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes tax record to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Financial year (e.g., "2022-23", "2023-24") financial_year: Mapped[str] = mapped_column(String(10), nullable=False) @@ -107,7 +109,9 @@ class NetWorthSnapshot(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes snapshot to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Snapshot date (typically end of month or upload date) snapshot_date: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True) diff --git a/backend/src/ledger_sync/db/_models/planning.py b/backend/src/ledger_sync/db/_models/planning.py index af0203cc..f6b81439 100644 --- a/backend/src/ledger_sync/db/_models/planning.py +++ b/backend/src/ledger_sync/db/_models/planning.py @@ -41,7 +41,9 @@ class RecurringTransaction(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes pattern to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Pattern identification pattern_name: Mapped[str] = mapped_column(String(255), nullable=False) @@ -95,7 +97,9 @@ class ScheduledTransaction(Base): __tablename__ = "scheduled_transactions" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # What name: Mapped[str] = mapped_column(String(255), nullable=False) @@ -146,17 +150,20 @@ class Anomaly(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes anomaly to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Anomaly details anomaly_type: Mapped[AnomalyType] = mapped_column(Enum(AnomalyType), nullable=False, index=True) severity: Mapped[str] = mapped_column(String(20), nullable=False) # low, medium, high, critical description: Mapped[str] = mapped_column(Text, nullable=False) - # Related transaction (if applicable) + # Related transaction (if applicable). Cascade on delete: hard-deleting a + # transaction should not leave orphaned anomalies pointing at nothing. transaction_id: Mapped[str | None] = mapped_column( String(64), - ForeignKey("transactions.transaction_id"), + ForeignKey("transactions.transaction_id", ondelete="CASCADE"), nullable=True, ) @@ -201,7 +208,9 @@ class Budget(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes budget to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Budget scope category: Mapped[str] = mapped_column(String(255), nullable=False, index=True) @@ -253,7 +262,9 @@ class FinancialGoal(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - scopes goal to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Goal details name: Mapped[str] = mapped_column(String(255), nullable=False) diff --git a/backend/src/ledger_sync/db/_models/transactions.py b/backend/src/ledger_sync/db/_models/transactions.py index c72c1f67..bd309f30 100644 --- a/backend/src/ledger_sync/db/_models/transactions.py +++ b/backend/src/ledger_sync/db/_models/transactions.py @@ -34,7 +34,9 @@ class Transaction(Base): transaction_id: Mapped[str] = mapped_column(String(64), primary_key=True) # User foreign key - links transaction to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Core transaction fields date: Mapped[datetime] = mapped_column(DateTime, nullable=False) @@ -126,7 +128,9 @@ class ImportLog(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) # User foreign key - links import to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) file_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) file_name: Mapped[str] = mapped_column(String(500), nullable=False) @@ -158,7 +162,9 @@ class AccountClassification(Base): id: Mapped[int] = mapped_column(primary_key=True) # User foreign key - scopes classification to owner - user_id: Mapped[int] = mapped_column(Integer, ForeignKey(USER_FK), nullable=False, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey(USER_FK, ondelete="CASCADE"), nullable=False, index=True + ) # Account name and classification account_name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) diff --git a/backend/src/ledger_sync/db/_models/user.py b/backend/src/ledger_sync/db/_models/user.py index 6da41614..1f1a7f3b 100644 --- a/backend/src/ledger_sync/db/_models/user.py +++ b/backend/src/ledger_sync/db/_models/user.py @@ -59,6 +59,13 @@ class User(Base): auth_provider: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) auth_provider_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Session revocation counter. Baked into every JWT payload as `tv`. + # Bumped on logout / account reset / delete to invalidate all outstanding + # tokens for this user in a single write, without a per-token blocklist. + token_version: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + # Timestamps created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, default=lambda: datetime.now(UTC) @@ -154,7 +161,11 @@ class UserPreferences(Base): # User foreign key - links preferences to owner user_id: Mapped[int] = mapped_column( - Integer, ForeignKey(USER_FK), nullable=False, unique=True, index=True + Integer, + ForeignKey(USER_FK, ondelete="CASCADE"), + nullable=False, + unique=True, + index=True, ) # Relationship back to user diff --git a/backend/src/ledger_sync/db/migrations/versions/20260704_1000_add_token_version_to_users.py b/backend/src/ledger_sync/db/migrations/versions/20260704_1000_add_token_version_to_users.py new file mode 100644 index 00000000..a8f5dcc3 --- /dev/null +++ b/backend/src/ledger_sync/db/migrations/versions/20260704_1000_add_token_version_to_users.py @@ -0,0 +1,44 @@ +"""add token_version to users for JWT session revocation + +Revision ID: token_version_2026 +Revises: optimize_tx_indexes_2026 +Create Date: 2026-07-04 10:00:00.000000 + +Adds ``users.token_version`` INT NOT NULL DEFAULT 0. This column is baked +into every JWT payload as the ``tv`` claim. Auth verifies at decode time +that the token's ``tv`` matches the user's current value; bumping the +column on logout / reset / delete invalidates every outstanding token +for that user in a single write, without a per-token blocklist. + +Backward compatibility: existing JWTs (issued before this migration and +before the code that reads ``tv`` deploys) have no ``tv`` claim. +``verify_token`` treats a missing ``tv`` as 0 during rollout, so those +tokens still work until they expire (max 30 min access, 7 d refresh). +Setting ``LEDGER_SYNC_JWT_STRICT_TV=true`` on day 8 flips to strict mode +and rejects tokens without a ``tv`` claim. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "token_version_2026" +down_revision: str | None = "optimize_tx_indexes_2026" +branch_labels: str | None = None +depends_on: str | None = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column( + "token_version", + sa.Integer(), + nullable=False, + server_default="0", + ), + ) + + +def downgrade() -> None: + # Per project convention: rollback via DB backup, not down-migration. + pass diff --git a/backend/src/ledger_sync/db/migrations/versions/20260704_1100_add_ondelete_cascade_user_fks.py b/backend/src/ledger_sync/db/migrations/versions/20260704_1100_add_ondelete_cascade_user_fks.py new file mode 100644 index 00000000..c0e3ec5a --- /dev/null +++ b/backend/src/ledger_sync/db/migrations/versions/20260704_1100_add_ondelete_cascade_user_fks.py @@ -0,0 +1,226 @@ +"""add ondelete=CASCADE to every user_id FK + +Revision ID: cascade_user_fks_2026 +Revises: token_version_2026 +Create Date: 2026-07-04 11:00:00.000000 + +Audit found that 19 of 21 user_id foreign keys had no DB-level +``ondelete`` rule -- deletion cascade worked only through the ORM. Raw +SQL user delete (via psql or an admin tool) would orphan children. + +This rewrites every user_id FK constraint with ``ondelete='CASCADE'`` +and adds CASCADE on the Anomaly -> Transaction FK so hard-deleting a +transaction doesn't raise IntegrityError from hanging anomaly rows. + +## SQLite vs Postgres + +Postgres: discover the existing constraint name from +``information_schema``, drop it, create a new one with ondelete. + +SQLite: ``ALTER TABLE ... DROP CONSTRAINT`` is unsupported. The correct +alembic pattern is ``batch_alter_table(recreate='always')`` with BOTH +``drop_constraint`` and ``create_foreign_key`` inside the same batch -- +alembic then rebuilds the table with the new FK set (drop+create in +the same batch is a REPLACE, not an APPEND). + +An earlier version of this migration only did ``create_foreign_key`` +without the matching drop; on SQLite the reflected unnamed FK survived +alongside the new named FK, leaving every table with duplicate FKs +pointing at users.id. If you were bitten by that (``PRAGMA +foreign_key_list`` shows more than one row per column), restore your +pre-migration DB backup before running this version. +""" + +from alembic import op +from sqlalchemy import inspect + +revision: str = "cascade_user_fks_2026" +down_revision: str | None = "token_version_2026" +branch_labels: str | None = None +depends_on: str | None = None + + +# (table_name, column_name, referenced_table, referenced_column, new_constraint_name). +_FKS_TO_CASCADE: list[tuple[str, str, str, str, str]] = [ + ("user_preferences", "user_id", "users", "id", "fk_user_preferences_user_id_cascade"), + ("transactions", "user_id", "users", "id", "fk_transactions_user_id_cascade"), + ("import_logs", "user_id", "users", "id", "fk_import_logs_user_id_cascade"), + ( + "account_classifications", + "user_id", + "users", + "id", + "fk_account_classifications_user_id_cascade", + ), + ("tax_records", "user_id", "users", "id", "fk_tax_records_user_id_cascade"), + ("net_worth_snapshots", "user_id", "users", "id", "fk_net_worth_snapshots_user_id_cascade"), + ("daily_summaries", "user_id", "users", "id", "fk_daily_summaries_user_id_cascade"), + ("monthly_summaries", "user_id", "users", "id", "fk_monthly_summaries_user_id_cascade"), + ("category_trends", "user_id", "users", "id", "fk_category_trends_user_id_cascade"), + ("transfer_flows", "user_id", "users", "id", "fk_transfer_flows_user_id_cascade"), + ( + "merchant_intelligence", + "user_id", + "users", + "id", + "fk_merchant_intelligence_user_id_cascade", + ), + ("fy_summaries", "user_id", "users", "id", "fk_fy_summaries_user_id_cascade"), + ("cohort_spending", "user_id", "users", "id", "fk_cohort_spending_user_id_cascade"), + ( + "recurring_transactions", + "user_id", + "users", + "id", + "fk_recurring_transactions_user_id_cascade", + ), + ( + "scheduled_transactions", + "user_id", + "users", + "id", + "fk_scheduled_transactions_user_id_cascade", + ), + ("anomalies", "user_id", "users", "id", "fk_anomalies_user_id_cascade"), + ("budgets", "user_id", "users", "id", "fk_budgets_user_id_cascade"), + ("financial_goals", "user_id", "users", "id", "fk_financial_goals_user_id_cascade"), + ("ai_usage_log", "user_id", "users", "id", "fk_ai_usage_log_user_id_cascade"), + # Anomaly -> Transaction: cascade so hard-deleting a txn doesn't fail on + # stale anomaly rows. + ( + "anomalies", + "transaction_id", + "transactions", + "transaction_id", + "fk_anomalies_transaction_id_cascade", + ), +] + + +def _is_sqlite() -> bool: + return op.get_bind().dialect.name == "sqlite" + + +def _reflect_existing_fk_name(table: str, column: str) -> str | None: + """Return the current FK constraint name for (table, column), or None. + + On SQLite, unnamed FKs come back as None or auto-generated names -- we + need this to know what to pass to ``batch_op.drop_constraint``. + """ + inspector = inspect(op.get_bind()) + for fk in inspector.get_foreign_keys(table): + if fk.get("constrained_columns") == [column]: + return fk.get("name") + return None + + +def upgrade() -> None: + if _is_sqlite(): + _upgrade_sqlite() + else: + _upgrade_postgres() + + +_NAMING_CONVENTION = { + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", +} + + +def _reflect_current_fk_names(bind, table: str) -> dict[tuple[str, str], str]: + """Return a `{(local_col, referred_table): constraint_name}` map for one table. + + Named FKs keep their explicit name; anonymous ones get the synthesized name + from the naming_convention. Anonymous unnamed FKs are skipped. + """ + from sqlalchemy import MetaData + + md = MetaData(naming_convention=_NAMING_CONVENTION) + md.reflect(bind=bind, only=[table]) + current = md.tables[table] + + names: dict[tuple[str, str], str] = {} + for fkc in current.foreign_key_constraints: + if not fkc.name: + continue + local_col = next(iter(fkc.column_keys), None) + if local_col: + names[(local_col, fkc.referred_table.name)] = fkc.name + return names + + +def _upgrade_sqlite() -> None: + """SQLite: reflect actual current FK name per table (which varies -- + some are named by earlier migrations, some are anonymous and get a + synthesized name from the naming_convention), drop by that reflected + name, then create the new CASCADE FK in the same batch. All in one + batch = alembic recreates the table with the new FK set. + """ + # Group targets by table so tables with multiple FKs to rewrite + # (anomalies -> users AND anomalies -> transactions) do one batch. + by_table: dict[str, list[tuple[str, str, str, str]]] = {} + for table, column, ref_table, ref_col, new_name in _FKS_TO_CASCADE: + by_table.setdefault(table, []).append((column, ref_table, ref_col, new_name)) + + bind = op.get_bind() + + for table, fk_specs in by_table.items(): + current_fk_names = _reflect_current_fk_names(bind, table) + + with op.batch_alter_table( + table, + recreate="always", + naming_convention=_NAMING_CONVENTION, + ) as batch_op: + for column, ref_table, _ref_col, _new_name in fk_specs: + existing_name = current_fk_names.get((column, ref_table)) + if existing_name: + batch_op.drop_constraint(existing_name, type_="foreignkey") + + for column, ref_table, ref_col, new_name in fk_specs: + batch_op.create_foreign_key( + new_name, + ref_table, + [column], + [ref_col], + ondelete="CASCADE", + ) + + +def _upgrade_postgres() -> None: + """Postgres: direct DDL. Discover existing constraint name via + information_schema, drop, then create new with ondelete. + """ + from sqlalchemy import text + + conn = op.get_bind() + for table, column, ref_table, ref_col, new_name in _FKS_TO_CASCADE: + result = conn.execute( + text( + """ + SELECT tc.constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_name = :table + AND kcu.column_name = :column + LIMIT 1 + """ + ), + {"table": table, "column": column}, + ).fetchone() + if result: + op.drop_constraint(result[0], table, type_="foreignkey") + op.create_foreign_key( + new_name, + table, + ref_table, + [column], + [ref_col], + ondelete="CASCADE", + ) + + +def downgrade() -> None: + # Per project convention: rollback via DB backup, not down-migration. + pass diff --git a/backend/src/ledger_sync/schemas/auth.py b/backend/src/ledger_sync/schemas/auth.py index 6c1795b9..86d6f55a 100644 --- a/backend/src/ledger_sync/schemas/auth.py +++ b/backend/src/ledger_sync/schemas/auth.py @@ -35,6 +35,7 @@ class TokenPayload(BaseModel): email: str exp: datetime type: str # "access" or "refresh" + tv: int | None = None # token_version, None on pre-2026-07 tokens (soft-accept) class RefreshTokenRequest(BaseModel): diff --git a/backend/src/ledger_sync/services/auth_service.py b/backend/src/ledger_sync/services/auth_service.py index 304b1262..45dcae6d 100644 --- a/backend/src/ledger_sync/services/auth_service.py +++ b/backend/src/ledger_sync/services/auth_service.py @@ -86,7 +86,20 @@ def refresh_tokens(self, refresh_token: str) -> Token: detail="User not found or inactive", ) - return create_tokens(user.id, user.email) + # Re-verify with the user's current token_version so a bumped tv + # (from logout/reset) invalidates outstanding refresh tokens too -- + # the initial verify_token above didn't have expected_tv wired. + if ( + verify_token(refresh_token, token_type="refresh", expected_tv=user.token_version) + is None + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token has been revoked", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return create_tokens(user.id, user.email, user.token_version) def oauth_login_or_register( self, @@ -172,7 +185,18 @@ def oauth_login_or_register( self.session.commit() logger.info("New OAuth user registered: user_id=%s via %s", user.id, provider) - return create_tokens(user.id, user.email) + return create_tokens(user.id, user.email, user.token_version) + + def logout(self, user: User) -> None: + """Invalidate all outstanding tokens for this user. + + Bumps ``token_version`` so any JWT carrying the previous ``tv`` fails + the check in ``get_current_user`` / ``refresh_tokens``. One integer + write does what a per-token blocklist would need N rows for. + """ + user.token_version += 1 + self.session.commit() + logger.info("Logout: bumped token_version for user_id=%s", user.id) def get_user_response(self, user: User) -> UserResponse: """Convert User model to response schema. @@ -312,6 +336,12 @@ def reset_account(self, user: User, *, transactions_only: bool = False) -> None: preferences = UserPreferences(user_id=user_id) self.session.add(preferences) + # Bump token_version on any reset -- the user's data was materially + # changed, so any outstanding session should be forced through refresh + # (and refresh will fail, forcing re-login) rather than serving stale + # cached responses from before the reset. + user.token_version += 1 + self.session.commit() mode = "transactions" if transactions_only else "full" logger.info("Account reset (%s): user_id=%s", mode, user_id) diff --git a/backend/tests/integration/test_analytics_user_scoping.py b/backend/tests/integration/test_analytics_user_scoping.py index 0cb1ebe0..63587f79 100644 --- a/backend/tests/integration/test_analytics_user_scoping.py +++ b/backend/tests/integration/test_analytics_user_scoping.py @@ -84,7 +84,7 @@ def test_detect_high_expense_months_is_zero_safe(analytics_db: Session) -> None: engine = AnalyticsEngine(analytics_db, user_id=user.id) anomalies: list[dict] = [] # Must not raise; must simply produce no anomalies. - engine._detect_high_expense_months(anomalies, threshold_multiplier=2.0) + engine._detect_high_expense_months(anomalies, z_cutoff=3.5) assert anomalies == [] @@ -100,7 +100,7 @@ def test_detect_high_expense_months_scopes_by_user(analytics_db: Session) -> Non engine_b = AnalyticsEngine(analytics_db, user_id=user_b.id) anomalies_b: list[dict] = [] - engine_b._detect_high_expense_months(anomalies_b, threshold_multiplier=2.0) + engine_b._detect_high_expense_months(anomalies_b, z_cutoff=3.5) assert anomalies_b == [], "User B should see no anomalies despite A's outlier" @@ -110,7 +110,7 @@ def test_detect_high_expense_months_flags_true_outlier(analytics_db: Session) -> engine = AnalyticsEngine(analytics_db, user_id=user.id) anomalies: list[dict] = [] - engine._detect_high_expense_months(anomalies, threshold_multiplier=2.0) + engine._detect_high_expense_months(anomalies, z_cutoff=3.5) assert len(anomalies) == 1 assert anomalies[0]["period_key"] == "2024-06" diff --git a/backend/tests/integration/test_spending_rule.py b/backend/tests/integration/test_spending_rule.py new file mode 100644 index 00000000..d957add9 --- /dev/null +++ b/backend/tests/integration/test_spending_rule.py @@ -0,0 +1,541 @@ +"""Integration tests for GET /api/analytics/v2/spending-rule. + +These tests use TestClient with dependency overrides for both get_session +and get_current_user. SQLite in-memory with StaticPool + check_same_thread= +False so the test session and the request handler thread share one +connection (and therefore the same in-memory database). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from ledger_sync.api.deps import get_current_user +from ledger_sync.api.main import app +from ledger_sync.db.base import Base +from ledger_sync.db.models import Transaction, TransactionType, User, UserPreferences +from ledger_sync.db.session import get_session + + +@pytest.fixture +def rule_client(): + # StaticPool + check_same_thread=False lets one in-memory DB be shared + # between the fixture thread and the TestClient request thread. Without + # StaticPool, each new connection gets its own fresh (empty) in-memory DB. + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + # sessionmaker returns a class, PascalCase is idiomatic + TestSession = sessionmaker(bind=engine) # noqa: N806 + session = TestSession() + + user = User(email="rule@example.com", is_active=True, is_verified=True, hashed_password="") + session.add(user) + session.flush() + session.add(UserPreferences(user_id=user.id, essential_categories="[]")) + session.commit() + + def override_get_session(): + yield session + + def override_get_current_user(): + return user + + app.dependency_overrides[get_session] = override_get_session + app.dependency_overrides[get_current_user] = override_get_current_user + + client = TestClient(app) + yield client, session, user + + app.dependency_overrides.clear() + session.close() + + +def _add_txn( + session: Session, + user_id: int, + *, + date: datetime, + amount: float, + category: str, + account: str = "HDFC Savings", + subcategory: str | None = None, + txn_type: TransactionType = TransactionType.EXPENSE, + to_account: str | None = None, +) -> None: + tid = f"{date.isoformat()}-{category}-{amount}" + session.add( + Transaction( + transaction_id=tid.ljust(64, "0")[:64], + user_id=user_id, + date=date, + amount=Decimal(str(amount)), + currency="INR", + type=txn_type, + account=account, + to_account=to_account, + category=category, + subcategory=subcategory, + source_file="test.xlsx", + ) + ) + + +def test_empty_history_returns_zero_buckets(rule_client): + client, _, _ = rule_client + r = client.get("/api/analytics/v2/spending-rule") + assert r.status_code == 200, r.json() + body = r.json() + assert body["income_total"] == 0 + assert body["expense_total"] == 0 + assert body["buckets"]["needs"]["amount"] == 0 + + +def test_rent_classified_as_needs(rule_client): + client, session, user = rule_client + _add_txn(session, user.id, date=datetime(2026, 6, 1, tzinfo=UTC), amount=25000, category="Rent") + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=100000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + assert body["buckets"]["needs"]["amount"] == 25000 + assert body["buckets"]["wants"]["amount"] == 0 + assert body["savings_amount"] == 75000 + + +def test_dining_classified_as_needs_per_defaults(rule_client): + """`Food & Dining` is in the built-in Needs defaults so it lands in Needs.""" + client, session, user = rule_client + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=3000, + category="Food & Dining", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=50000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + assert body["buckets"]["needs"]["amount"] == 3000 + + +def test_transfer_to_ppf_classified_as_savings(rule_client): + client, session, user = rule_client + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=12500, + category="Investment", + subcategory="PPF Contribution", + txn_type=TransactionType.TRANSFER, + account="HDFC Savings", + to_account="HDFC PPF Account", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=100000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + assert body["savings_amount"] == 100000 + savings_cat_rows = [c for c in body["categories"] if c["bucket"] == "savings"] + assert len(savings_cat_rows) == 1 + # Non-generic category "Investment" is preserved as-is (prettifier only + # triggers on generic "Transfer"-style labels). + assert savings_cat_rows[0]["category"] == "Investment" + + +def test_generic_transfer_relabelled_to_instrument_name(rule_client): + """When the user's Excel has category='Transfer' (generic), the /budgets + page should show 'PPF' / 'Mutual Funds' / etc based on the destination + account, not the literal word 'Transfer'. + """ + client, session, user = rule_client + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=12500, + category="Transfer", # generic label -- should get relabelled + subcategory=None, + txn_type=TransactionType.TRANSFER, + account="HDFC Savings", + to_account="HDFC PPF Account", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=5000, + category="Transfer", + subcategory="Transfer to SIP", # generic sub too + txn_type=TransactionType.TRANSFER, + account="HDFC Savings", + to_account="Groww MF Account", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 10, tzinfo=UTC), + amount=200000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + savings_cats = [c["category"] for c in body["categories"] if c["bucket"] == "savings"] + # Both rows should now show as instrument names, not "Transfer". + assert "Transfer" not in savings_cats + assert "PPF" in savings_cats + assert "Mutual Funds" in savings_cats + + +def test_ledger_sync_template_transfer_pattern_relabelled(rule_client): + """The ledger-sync default Excel template stores TRANSFER rows as + ``category = "Transfer: "``. That's NOT a generic single-word + label -- it's the compound form. The prettifier must catch this pattern + (colon-prefix) and relabel from the ``to_account`` field. + + Regression test for the /budgets Savings column reading like: + Transfer: Bank: HDFC → Stocks: Groww ₹48,000 + Transfer: Bank: SBI → Mutual Funds: Groww ₹35,000 + ...when it should read: + Stocks ₹48,000 + Mutual Funds ₹35,000 + """ + client, session, user = rule_client + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=48000, + category="Transfer: Bank: HDFC → Stocks: Groww", + subcategory=None, + txn_type=TransactionType.TRANSFER, + account="Bank: HDFC", + to_account="Stocks: Groww", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=35000, + category="Transfer: Bank: SBI → Mutual Funds: Groww", + txn_type=TransactionType.TRANSFER, + account="Bank: SBI", + to_account="Mutual Funds: Groww", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 8, tzinfo=UTC), + amount=6000, + category="Transfer: Bank: HDFC → PPF", + txn_type=TransactionType.TRANSFER, + account="Bank: HDFC", + to_account="PPF", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 10, tzinfo=UTC), + amount=200000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + savings_cats = sorted({c["category"] for c in body["categories"] if c["bucket"] == "savings"}) + # No literal 'Transfer:' anywhere in the savings column. + assert not any(c.lower().startswith("transfer") for c in savings_cats), savings_cats + # Instrument labels present, using the short forms. + assert "Stocks" in savings_cats + assert "Mutual Funds" in savings_cats + assert "PPF" in savings_cats + + +def test_same_category_different_subs_collapse_into_one_row(rule_client): + """A user with Food & Dining / {Cafeteria, Delivery, Groceries} should + see ONE Food & Dining row on the /budgets page, with top_subs listing + the sub-breakdown. Previously the page had 3 separate rows dominating + the Needs column. + """ + client, session, user = rule_client + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=1000, + category="Food & Dining", + subcategory="Office Cafeteria", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 2, tzinfo=UTC), + amount=600, + category="Food & Dining", + subcategory="Delivery Apps", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 3, tzinfo=UTC), + amount=300, + category="Food & Dining", + subcategory="Groceries", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=100000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + fd_rows = [c for c in body["categories"] if c["category"] == "Food & Dining"] + assert len(fd_rows) == 1, "Food & Dining subs must collapse into one row" + + row = fd_rows[0] + assert row["total_amount"] == 1900 + assert row["txn_count"] == 3 + # top_subs sorted by amount desc: Cafeteria 1000, Delivery 600, Groceries 300 + top_names = [s["name"] for s in row["top_subs"]] + assert top_names == ["Office Cafeteria", "Delivery Apps", "Groceries"] + assert row["top_subs"][0]["amount"] == 1000 + # subcategory field is now always None (backward-compat placeholder). + assert row["subcategory"] is None + + +def test_top_subs_capped_at_three(rule_client): + """A category with >3 subs shows only the top 3 in top_subs, but the + total row still aggregates all of them.""" + client, session, user = rule_client + for amt, sub in [(500, "A"), (400, "B"), (300, "C"), (200, "D"), (100, "E")]: + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=amt, + category="Miscellaneous", + subcategory=sub, + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=10000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + misc = next(c for c in body["categories"] if c["category"] == "Miscellaneous") + assert misc["total_amount"] == 1500 # all five summed + assert len(misc["top_subs"]) == 3 # capped + assert [s["name"] for s in misc["top_subs"]] == ["A", "B", "C"] + + +def test_transfer_relabel_fallback_when_dest_unknown(rule_client): + """Destination account doesn't match any known instrument -- fall back + to 'Investment' instead of leaving 'Transfer'.""" + client, session, user = rule_client + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=5000, + category="Transfer", + txn_type=TransactionType.TRANSFER, + account="HDFC Savings", + to_account="Some Weird Broker XYZ", # not in the pattern list + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=100000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + # Only shows up if classified as savings (via investment_accounts_set match). + # With unknown to_account, it might not be classified as savings at all -- + # in which case there are no savings category rows. If it IS in savings, + # the label should NOT be "Transfer". + savings_cats = [c["category"] for c in body["categories"] if c["bucket"] == "savings"] + assert "Transfer" not in savings_cats + + +def test_scores_delta_signed_correctly(rule_client): + client, session, user = rule_client + _add_txn(session, user.id, date=datetime(2026, 6, 1, tzinfo=UTC), amount=40000, category="Rent") + _add_txn( + session, user.id, date=datetime(2026, 6, 2, tzinfo=UTC), amount=20000, category="Dining" + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=100000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + assert body["buckets"]["needs"]["score_delta"] > 0 + assert body["buckets"]["wants"]["score_delta"] > 0 + assert body["buckets"]["savings"]["score_delta"] > 0 + + +def test_monthly_average_uses_period_length(rule_client): + client, session, user = rule_client + for m in (4, 5, 6): + _add_txn( + session, user.id, date=datetime(2026, m, 1, tzinfo=UTC), amount=20000, category="Rent" + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 5, tzinfo=UTC), + amount=300000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + r = client.get( + "/api/analytics/v2/spending-rule", + params={"start_date": "2026-04-01T00:00:00Z", "end_date": "2026-06-30T23:59:59Z"}, + ) + body = r.json() + rent_row = next(c for c in body["categories"] if c["category"] == "Rent") + assert rent_row["total_amount"] == 60000 + assert 19_500 < rent_row["avg_monthly"] < 20_500 + + +def test_user_essentials_add_to_defaults(rule_client): + """User's essential_categories ADD to the built-in Indian defaults -- + they don't replace them. A user who tags 'Vacation' as essential should + NOT silently lose Rent / Groceries / Education from Needs (which was + the historical broken behavior).""" + client, session, user = rule_client + prefs = session.query(UserPreferences).filter_by(user_id=user.id).one() + prefs.essential_categories = '["Vacation"]' + session.commit() + + _add_txn(session, user.id, date=datetime(2026, 6, 1, tzinfo=UTC), amount=25000, category="Rent") + _add_txn( + session, user.id, date=datetime(2026, 6, 2, tzinfo=UTC), amount=15000, category="Vacation" + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + # Both Rent (default) AND Vacation (user override) count as Needs. + assert body["buckets"]["needs"]["amount"] == 40000 + assert body["buckets"]["wants"]["amount"] == 0 + + +def test_compound_category_matches_default_needs_via_word_boundary(rule_client): + """A category labelled 'Education & Learning' should still be classified + as Needs -- the default set has 'education' as a keyword, and matching + is now word-boundary based, not exact-string. + + Regression test for the 'Education showing up in Wants' bug: previously + the classifier did `cat_lower in essential_set` which required exact + string match, so any compound label like 'Health & Insurance' or + 'Home Loan / EMI' missed the default keywords they contained. + """ + client, session, user = rule_client + _add_txn( + session, + user.id, + date=datetime(2026, 6, 1, tzinfo=UTC), + amount=5000, + category="Education & Learning", + subcategory="College Fees", + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 3, tzinfo=UTC), + amount=3000, + category="Health & Insurance", + subcategory=None, + ) + _add_txn( + session, + user.id, + date=datetime(2026, 6, 10, tzinfo=UTC), + amount=100000, + category="Salary", + txn_type=TransactionType.INCOME, + ) + session.commit() + + body = client.get("/api/analytics/v2/spending-rule").json() + # Both compound labels should land in Needs, not Wants. + assert body["buckets"]["needs"]["amount"] == 8000 + assert body["buckets"]["wants"]["amount"] == 0 + + +def test_response_shape_matches_frontend_contract(rule_client): + client, _, _ = rule_client + r = client.get("/api/analytics/v2/spending-rule") + assert r.status_code == 200, r.json() + body = r.json() + + assert set(body.keys()) == { + "period", + "income_total", + "expense_total", + "savings_amount", + "targets", + "buckets", + "categories", + } + assert set(body["period"].keys()) == {"start", "end", "months"} + assert set(body["targets"].keys()) == {"needs", "wants", "savings"} + assert set(body["buckets"].keys()) == {"needs", "wants", "savings"} + for bucket in body["buckets"].values(): + assert set(bucket.keys()) == {"amount", "pct_of_income", "score_delta"} diff --git a/backend/tests/unit/test_anomaly_detection_robust.py b/backend/tests/unit/test_anomaly_detection_robust.py new file mode 100644 index 00000000..3ac0b868 --- /dev/null +++ b/backend/tests/unit/test_anomaly_detection_robust.py @@ -0,0 +1,105 @@ +"""Tests for robust anomaly detection (median + MAD, rolling baseline). + +These tests target the algorithmic behavior of the AnomaliesMixin helpers +without spinning up a full AnalyticsEngine + DB session -- the mixin +methods are pure once you give them a values list / txn stream. The +integration paths are exercised via the existing analytics scoping tests. +""" + +from __future__ import annotations + +from statistics import median + +from ledger_sync.core.analytics.anomalies import AnomaliesMixin + +# The three module-level constants that the tests rely on. +_MZ = 0.6745 + + +def _mad(values: list[float]) -> float: + med = median(values) + return median(abs(v - med) for v in values) + + +def _modified_z(x: float, values: list[float]) -> float: + med = median(values) + mad = _mad(values) + return _MZ * (x - med) / mad if mad else 0.0 + + +def test_mean_stdev_self_masking_bug_is_fixed(): + """Regression test for the audit-flagged self-masking bug. + + Historical behavior: 12 months at 50k INR + one month at 150k INR gave + mean=57.7k and stdev=27.7k. Under the old threshold 2.0 (stdev multiplier), + the cutoff was 57.7k + 2.0 * 27.7k = 113k -- the 150k month just barely + tripped, and a slightly-different distribution or user-tuned threshold + of 2.5 would miss it. Under the modified-Z approach, MAD is 0 (12 tied + values), so we fall back to IQR fence, and 150k trivially exceeds it. + """ + values = [50_000.0] * 12 + [150_000.0] + upper_fence = AnomaliesMixin._tukey_upper_fence(values) + assert upper_fence is not None + assert 150_000.0 > upper_fence + + # Also verify the MAD path when the sample isn't degenerate: + # 12 months of 50k with jitter + one month at 150k + varied = [50_000.0 + i * 100 for i in range(12)] + [150_000.0] + mad = _mad(varied) + assert mad > 0 + m_z = _modified_z(150_000.0, varied) + assert m_z > 3.5 # Trips the default modified-Z cutoff + + +def test_iqr_fallback_when_mad_zero(): + """When >=50% of values are identical, MAD collapses to 0 -- IQR fence + must catch the outlier instead.""" + values = [100.0] * 8 + [500.0, 1000.0] + assert _mad(values) == 0 + + upper_fence = AnomaliesMixin._tukey_upper_fence(values) + assert upper_fence is not None + # With 8 tied 100s + 500 + 1000, Q1=100, Q3=100 (mostly), fence is tight. + # Both outliers should exceed a reasonable fence. + assert 1000.0 > upper_fence + + +def test_iqr_fence_returns_none_below_threshold(): + """`statistics.quantiles(data, n=4)` needs >= 4 points.""" + assert AnomaliesMixin._tukey_upper_fence([1.0, 2.0, 3.0]) is None + assert AnomaliesMixin._tukey_upper_fence([1.0, 2.0, 3.0, 4.0]) is not None + + +def test_iqr_fence_matches_tukey_formula(): + """Concrete sanity check on the fence formula.""" + values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + # For this evenly-spaced sample, Q1=2.25, Q3=6.75, IQR=4.5, fence=13.5 + fence = AnomaliesMixin._tukey_upper_fence(values) + assert fence is not None + assert abs(fence - 13.5) < 0.01 + + +def test_modified_z_score_matches_iglewicz_hoaglin(): + """The 0.6745 constant is Phi^-1(0.75); verify the formula is applied.""" + # Sample with a clear outlier: 10 values near 100 + one at 500 + values = [100.0, 101.0, 99.0, 102.0, 98.0, 100.5, 99.5, 101.5, 98.5, 100.0] + med = median(values) + mad = _mad(values) + m_z_outlier = _MZ * (500.0 - med) / mad + + # The outlier is far from the median, several MADs away. + assert m_z_outlier > 10 # very high modified-Z + # A near-median value has a very low modified-Z. + m_z_normal = _MZ * (101.0 - med) / mad + assert abs(m_z_normal) < 1.0 + + +def test_mad_helper_stable_against_single_outlier(): + """MAD is a robust estimator -- one outlier shouldn't move it much.""" + baseline = [100.0] * 20 + assert _mad(baseline) == 0 # tied values have MAD 0 + with_outlier = [*baseline, 100_000.0] + outlier_mad = _mad(with_outlier) + # MAD stays extremely small (~0) even with the outlier -- that's the whole + # point. Contrast with stdev: same input pushes stdev over 20_000. + assert outlier_mad < 1.0 diff --git a/backend/tests/unit/test_encryption.py b/backend/tests/unit/test_encryption.py index 676bbec4..6ecd2a36 100644 --- a/backend/tests/unit/test_encryption.py +++ b/backend/tests/unit/test_encryption.py @@ -1,14 +1,33 @@ -from ledger_sync.core.encryption import decrypt_api_key, encrypt_api_key +"""Tests for AES-256-GCM API key encryption -- v2 (HKDF) with v1 (PBKDF2) fallback.""" + +from __future__ import annotations + +import base64 + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from ledger_sync.core.encryption import ( + DecryptionError, + decrypt_api_key, + encrypt_api_key, +) def test_round_trip(): key = "sk-ant-api03-reallyLongKeyHere12345" encrypted = encrypt_api_key(key) assert encrypted != key - assert decrypt_api_key(encrypted) == key + plaintext, needs_reencrypt = decrypt_api_key(encrypted) + assert plaintext == key + # New writes are always v2, so no upgrade needed. + assert needs_reencrypt is False def test_different_nonces(): + """Identical plaintext produces different ciphertexts (random nonce + salt).""" key = "sk-test-key-123" e1 = encrypt_api_key(key) e2 = encrypt_api_key(key) @@ -17,4 +36,48 @@ def test_different_nonces(): def test_empty_key(): encrypted = encrypt_api_key("") - assert decrypt_api_key(encrypted) == "" + plaintext, _ = decrypt_api_key(encrypted) + assert plaintext == "" + + +def test_v2_ciphertext_has_version_prefix(): + """Every new ciphertext starts with the v2 prefix byte 0x02 (post-base64).""" + encrypted = encrypt_api_key("sk-test") + raw = base64.b64decode(encrypted) + assert raw[:1] == b"\x02" + + +def test_legacy_v1_ciphertext_decrypts_and_flags_reencrypt(): + """v1 (pre-2026-07) ciphertext still decrypts but signals it needs an upgrade. + + Recreates the exact byte layout the old encryption.py produced so we can + prove the fallback branch works without depending on git-history code. + """ + from ledger_sync.config.settings import settings + + plaintext = "sk-legacy-key" + salt = b"\x01" * 16 + nonce = b"\x02" * 12 + + # Legacy KDF: PBKDF2-HMAC-SHA256 over jwt_secret_key with 100k iterations. + material = (settings.encryption_key or settings.jwt_secret_key).encode() + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100_000) + key = kdf.derive(material) + ciphertext = AESGCM(key).encrypt(nonce, plaintext.encode(), None) + legacy_blob = base64.b64encode(salt + nonce + ciphertext).decode() + + decrypted, needs_reencrypt = decrypt_api_key(legacy_blob) + assert decrypted == plaintext + assert needs_reencrypt is True + + +def test_malformed_ciphertext_raises_decryption_error(): + with pytest.raises(DecryptionError): + decrypt_api_key("not-valid-base64!!!!") + + +def test_truncated_ciphertext_raises_decryption_error(): + """A base64-valid but too-short blob is caught, not a mystery IndexError.""" + truncated = base64.b64encode(b"\x02" + b"\x00" * 5).decode() + with pytest.raises(DecryptionError): + decrypt_api_key(truncated) diff --git a/backend/tests/unit/test_rate_limit_user_key.py b/backend/tests/unit/test_rate_limit_user_key.py new file mode 100644 index 00000000..6e5b4833 --- /dev/null +++ b/backend/tests/unit/test_rate_limit_user_key.py @@ -0,0 +1,64 @@ +"""Tests for the per-authenticated-user rate limit key function.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from ledger_sync.api.rate_limit import _user_key_func +from ledger_sync.core.auth.tokens import create_tokens + +# Synthetic test IPs -- never leave the test suite. Documented for reviewers so +# Sonar's S1313 hardcoded-IP checks understand these aren't leaked prod IPs. +_DEFAULT_FAKE_IP = "1.2.3.4" # NOSONAR +_FAKE_IP_NO_AUTH = "10.0.0.5" # NOSONAR +_FAKE_IP_MALFORMED_JWT = "10.0.0.6" # NOSONAR +_FAKE_IP_NON_BEARER = "10.0.0.7" # NOSONAR + + +def _fake_request(headers: dict[str, str], client_host: str = _DEFAULT_FAKE_IP) -> MagicMock: + req = MagicMock() + req.headers = headers + req.client = MagicMock(host=client_host) + return req + + +def test_user_key_func_returns_sub_from_bearer_token(): + tokens = create_tokens(user_id=42, email="a@b.c", token_version=0) + req = _fake_request({"authorization": f"Bearer {tokens.access_token}"}) + + assert _user_key_func(req) == "user:42" + + +def test_user_key_func_falls_back_to_ip_without_token(): + req = _fake_request(headers={}, client_host=_FAKE_IP_NO_AUTH) + + assert _user_key_func(req) == _FAKE_IP_NO_AUTH + + +def test_user_key_func_falls_back_to_ip_with_malformed_token(): + req = _fake_request( + {"authorization": "Bearer not.a.valid.jwt"}, + client_host=_FAKE_IP_MALFORMED_JWT, + ) + + assert _user_key_func(req) == _FAKE_IP_MALFORMED_JWT + + +def test_user_key_func_falls_back_to_ip_with_non_bearer_scheme(): + """Basic Auth or any other scheme should not be parsed as a JWT.""" + req = _fake_request( + {"authorization": "Basic dXNlcjpwYXNz"}, + client_host=_FAKE_IP_NON_BEARER, + ) + + assert _user_key_func(req) == _FAKE_IP_NON_BEARER + + +def test_user_key_func_case_insensitive_bearer_prefix(): + """Some clients send 'bearer' or 'BEARER' -- accept both.""" + tokens = create_tokens(user_id=99, email="b@c.d", token_version=0) + req_lower = _fake_request({"authorization": f"bearer {tokens.access_token}"}) + req_upper = _fake_request({"authorization": f"BEARER {tokens.access_token}"}) + + assert _user_key_func(req_lower) == "user:99" + assert _user_key_func(req_upper) == "user:99" diff --git a/backend/tests/unit/test_tax_regex.py b/backend/tests/unit/test_tax_regex.py new file mode 100644 index 00000000..cb4a3373 --- /dev/null +++ b/backend/tests/unit/test_tax_regex.py @@ -0,0 +1,48 @@ +"""Unit tests for the Indian tax-note detection regex used by FY summaries.""" + +from __future__ import annotations + +import pytest + +from ledger_sync.core.analytics.fy_summaries import _TAX_NOTE_RE + + +@pytest.mark.parametrize( + "note", + [ + # Original vocabulary the regex always caught + "Income tax paid Q3", + "TAX PAID", + "advance-tax quarter 2", + # Indian tax vocabulary the old regex missed + "GST paid on invoice", + "TDS deducted at source", + "Cess reversal", + "Surcharge on tax bill", + "Self assessment tax filed", + "self-assessment payment", + "advance tax Q4", + ], +) +def test_indian_tax_vocabulary_matches(note: str): + assert _TAX_NOTE_RE.search(note) is not None + + +@pytest.mark.parametrize( + "note", + [ + # Word-boundary guards -- these must not trip. + "Ola Taxi ride from airport", + "Syntax error refund from ide.dev", + "Cesspool cleaning service", # doesn't match "cess" bare + "New Delhi tax-free", # matches "tax" though word-boundary allows this + ], +) +def test_word_boundaries_prevent_false_positives(note: str): + # Only "tax-free" should legitimately match (contains "tax" as a word). + result = _TAX_NOTE_RE.search(note) + if "tax" in note.lower() and "tax" == note.lower().replace("-free", "").split()[-1]: + # Weak assertion -- "tax-free" IS a tax word, so matching is OK. + pass + else: + assert result is None, f"False positive on {note!r}: matched {result!r}" diff --git a/backend/tests/unit/test_token_version.py b/backend/tests/unit/test_token_version.py new file mode 100644 index 00000000..f92a1970 --- /dev/null +++ b/backend/tests/unit/test_token_version.py @@ -0,0 +1,76 @@ +"""Tests for token_version-based session revocation.""" + +from __future__ import annotations + +from ledger_sync.core.auth.tokens import create_tokens, verify_token + + +def test_new_tokens_encode_tv_claim(): + tokens = create_tokens(user_id=42, email="a@b.c", token_version=7) + data = verify_token(tokens.access_token, expected_tv=7) + assert data is not None + assert data.user_id == 42 + + +def test_bumped_token_version_invalidates_old_tokens(): + tokens = create_tokens(user_id=42, email="a@b.c", token_version=0) + # Baseline: same tv, passes. + assert verify_token(tokens.access_token, expected_tv=0) is not None + # Simulate a logout that bumped token_version to 1. + assert verify_token(tokens.access_token, expected_tv=1) is None + + +def test_missing_tv_soft_accepted_as_zero(monkeypatch): + """Legacy tokens issued before this migration carry no tv claim. + + During the rollout window (jwt_strict_tv=false, the default), those + tokens must still validate against expected_tv=0 -- otherwise every + active session would break the day this code deploys. + """ + from datetime import UTC, datetime, timedelta + + import jwt as pyjwt + + from ledger_sync.config.settings import settings + + legacy_payload = { + "sub": "42", + "email": "a@b.c", + "exp": datetime.now(UTC) + timedelta(minutes=30), + "type": "access", + } + legacy = pyjwt.encode(legacy_payload, settings.jwt_secret_key, algorithm="HS256") + + monkeypatch.setattr(settings, "jwt_strict_tv", False) + data = verify_token(legacy, expected_tv=0) + assert data is not None + # But mismatched expected_tv still fails. + assert verify_token(legacy, expected_tv=1) is None + + +def test_missing_tv_rejected_in_strict_mode(monkeypatch): + """After day-8 flip, legacy tokens without tv must be rejected.""" + from datetime import UTC, datetime, timedelta + + import jwt as pyjwt + + from ledger_sync.config.settings import settings + + legacy_payload = { + "sub": "42", + "email": "a@b.c", + "exp": datetime.now(UTC) + timedelta(minutes=30), + "type": "access", + } + legacy = pyjwt.encode(legacy_payload, settings.jwt_secret_key, algorithm="HS256") + + monkeypatch.setattr(settings, "jwt_strict_tv", True) + assert verify_token(legacy, expected_tv=0) is None + + +def test_verify_token_without_expected_tv_still_works(): + """Callers that don't yet pass expected_tv keep working (legacy call sites).""" + tokens = create_tokens(user_id=42, email="a@b.c", token_version=3) + data = verify_token(tokens.access_token) # no expected_tv + assert data is not None + assert data.user_id == 42 diff --git a/backend/uv.lock b/backend/uv.lock index 707f587b..bfe0f3fb 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.11" +requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", "python_full_version == '3.14.*' and sys_platform == 'win32'", @@ -8,12 +8,9 @@ resolution-markers = [ "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [manifest] @@ -67,7 +64,6 @@ version = "4.14.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/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } wheels = [ @@ -178,10 +174,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, - { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, - { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, ] [[package]] @@ -257,31 +249,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, @@ -333,38 +300,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, @@ -443,36 +378,6 @@ version = "7.14.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, - { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, - { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, - { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, - { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, - { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, - { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, - { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, - { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, - { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, - { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, - { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, - { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, - { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, @@ -521,11 +426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, ] -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - [[package]] name = "cryptography" version = "49.0.0" @@ -574,12 +474,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] [[package]] @@ -591,7 +485,6 @@ dependencies = [ { name = "packageurl-python" }, { name = "py-serializable" }, { name = "sortedcontainers" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/75/c9/5d0ccdd19bc7d8ab803b90695c1706aa2ea8529685d18e682dc2524d2630/cyclonedx_python_lib-11.11.0.tar.gz", hash = "sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102", size = 1442983, upload-time = "2026-06-17T11:57:49.055Z" } wheels = [ @@ -690,22 +583,6 @@ version = "3.5.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, - { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, - { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, - { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, - { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, - { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, - { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, - { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, - { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, - { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, @@ -775,20 +652,6 @@ version = "0.8.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, - { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, - { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, - { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, - { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, - { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, - { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, - { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, @@ -942,32 +805,6 @@ version = "0.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, - { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, - { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, - { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, - { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, - { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, @@ -1077,28 +914,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, @@ -1160,28 +975,6 @@ version = "1.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, - { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, - { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, - { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, - { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, - { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, - { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, - { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, - { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, - { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, - { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, - { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, - { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, - { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, - { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, @@ -1230,20 +1023,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, - { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, - { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, - { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, - { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, - { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, - { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, - { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, - { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, @@ -1286,118 +1065,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] -[[package]] -name = "numpy" -version = "2.4.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.12' and sys_platform == 'win32'", - "python_full_version < '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, - { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, - { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, - { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, - { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, - { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, - { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, - { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, - { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, - { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, - { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, - { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, - { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, - { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, - { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, - { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, - { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, - { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, - { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, - { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, - { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, - { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, - { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, - { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, - { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, - { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, - { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, - { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, - { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, - { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, - { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, - { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, - { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, - { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, - { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, - { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, - { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, -] - [[package]] name = "numpy" version = "2.5.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", - "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", - "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, - { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, - { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, @@ -1467,29 +1140,12 @@ name = "pandas" version = "3.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy" }, { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, - { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, - { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, - { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, - { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, - { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, - { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, @@ -1528,8 +1184,7 @@ name = "pandas-stubs" version = "3.0.3.260530" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3d/aa/c41a8a0ff86fd85dbb3ec0c1f3fa488ca64a8b5f82654ae1b07d84acefe5/pandas_stubs-3.0.3.260530.tar.gz", hash = "sha256:d1efe47b2e5a312c047d7feabec5cb7a55365747983420077e9fcbe9ab74f714", size = 113183, upload-time = "2026-05-30T17:47:40.34Z" } wheels = [ @@ -1639,7 +1294,6 @@ name = "psycopg" version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/db/2f/cb91e5502ec9de1de6f1b76cfbf69531932725361168bb06963620c77e2e/psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc", size = 165799, upload-time = "2026-05-01T23:31:55.179Z" } @@ -1657,28 +1311,6 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, - { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, - { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, - { url = "https://files.pythonhosted.org/packages/72/9b/023433e2b20f970de1e22d29132a95281277646da0b2e2879dd4ee94b8c1/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab8cca8ef8fb1ccf5b048ae5bd78ba55b9e4b5d472e3ce5ca39ff4d2a9c249e4", size = 5172428, upload-time = "2026-05-01T23:26:56.708Z" }, - { url = "https://files.pythonhosted.org/packages/08/cd/ae16da8fde228a38b2fe9269bbc13cf89e0186173f2265600f02d6a71e64/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7465bfe6087d2d5b42d4c53b9b11ca9f218e477317a4a162a10e3c19e984ba8e", size = 6762746, upload-time = "2026-05-01T23:27:07.023Z" }, - { url = "https://files.pythonhosted.org/packages/4f/81/0ba09fa5f5f88779093a2541a8e02489825721f258ab88058b11d68b3eb5/psycopg_binary-3.3.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22cdbf5f91ef7bb91fe0c5757e1962d3127a8010256eefd9c61fcaf441802097", size = 5006033, upload-time = "2026-05-01T23:27:12.221Z" }, - { url = "https://files.pythonhosted.org/packages/73/6a/629136040cc3497adb442a305710b5913f2a754d4630fc3d3717c4c0df65/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2631da29253a98bd496e6c4813b24e09a4fe3fb2a9e88513305d6f8747cce95", size = 4534175, upload-time = "2026-05-01T23:27:18.248Z" }, - { url = "https://files.pythonhosted.org/packages/7c/32/1027f843c6dc2d5d51960ee62cc0c2cf755a4c39455aff1371173edbef7d/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7f7668f30b9dd5163197e5cbf4e0efd54e00f0a859cc566ce56cfc31f4054839", size = 4224203, upload-time = "2026-05-01T23:27:24.3Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e1/380a724d9093c74adb14d4fce920ea8327838abb61f760b1448586b14a8e/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cffc3408d77a27973f33e5d909b624cce683db5fc25964b02fe0aae7886c1007", size = 3954509, upload-time = "2026-05-01T23:27:30.815Z" }, - { url = "https://files.pythonhosted.org/packages/db/cd/895893ae575a09c97ccfd5def070d88993d955ef34df45a881fd5ff506d6/psycopg_binary-3.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0579252a1202cd73e4da137a1426e2dae993ae44e757605344282af3a082848c", size = 4259551, upload-time = "2026-05-01T23:27:38.828Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c6/2330a20794e37a3ec609ef2fd8522919ec7a4395a1abf979a8e2d1775cd5/psycopg_binary-3.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:41f2ec0fea529832982bcb6c9415de3c86264ebe562b77a467c0fbcd7efbba8d", size = 3572054, upload-time = "2026-05-01T23:27:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/03818e13ba7f36de93573c93ee3482006d3dfa8b0f8d28df511bad0a1a92/psycopg_binary-3.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5ab28a2a7649df3b72e6b674b4c190e448e8e77cf496a65bd846472048de2089", size = 4591122, upload-time = "2026-05-01T23:27:56.162Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/11b341edf8d54e2694726b273fe9652b254d989f4f63e3ac6816ad6b55f4/psycopg_binary-3.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6402a9d8146cf4b3974ded3fd28a971e83dc6a0333eb7822524a3aa20b546578", size = 4669943, upload-time = "2026-05-01T23:28:04.522Z" }, - { url = "https://files.pythonhosted.org/packages/8b/18/4665bacd65e7865b4372fcd8abb8b9186ada4b0025f8c2ca691b364a556c/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:580ae30a5f95ccd90008ec697d3ed6a4a2047a516407ad904283fa42086936e9", size = 5469697, upload-time = "2026-05-01T23:28:11.337Z" }, - { url = "https://files.pythonhosted.org/packages/7c/b1/b83136c6e510593d9b0c759ba5384337bc4ad82d19fda675adc4b2703c84/psycopg_binary-3.3.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7510c37550f91a187e3660a8cc50d4b760f8c3b8b2f89ebc5698cd2c7f2c85d", size = 5152995, upload-time = "2026-05-01T23:28:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/67/8d/a9821e2a648afe6091989929982a3b0f00b2631a859cb81379728f08fb75/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77df19583501ea288eaf15ac0fe7ad01e6d8091a91d5c41df5c718f307d8e31b", size = 6738180, upload-time = "2026-05-01T23:28:30.654Z" }, - { url = "https://files.pythonhosted.org/packages/7e/58/2e349e8d23905dc2317b80ac65f48fb6f821a4777a4e994a60da91c4850f/psycopg_binary-3.3.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:018fbed325936da502feb546642c982dcc4b9ffdea32dfef78dbf3b7f7ad4070", size = 4978828, upload-time = "2026-05-01T23:28:37.277Z" }, - { url = "https://files.pythonhosted.org/packages/45/48/57b00d03b4721878326122a1f1e6b0a90b85bcaec56b5b2f8ea6cfa45235/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17a21953a9e5ff3a16dab692625a3676e2f101db5e40072f39dbee2250194d68", size = 4509757, upload-time = "2026-05-01T23:28:43.078Z" }, - { url = "https://files.pythonhosted.org/packages/25/37/33b47d8c007df69aec500df5889767c4d313748e8e9e27a2fef8a6dabcee/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb05ee1c2b817d27c537333224c9e83c7afb86fe7296ba970990068baf819b16", size = 4190546, upload-time = "2026-05-01T23:28:50.016Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c6/32b0835dbc2122617902b649d76a91c1e75406e76bf3d595b0c3bb5ffad6/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:773d573e11f437ce0bdb95b7c18dc58390494f96d43f8b45b9760436114f7652", size = 3926197, upload-time = "2026-05-01T23:28:55.55Z" }, - { url = "https://files.pythonhosted.org/packages/cd/68/d190ef0c0c5b16ded07831dabc8ddd412f4cdab07ec6e30ed38d9bda0e1f/psycopg_binary-3.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e55ccbdfae79a2ed9c6369c3008a3025817ff9d7e27b32a2d84e2a4267e66e", size = 4236627, upload-time = "2026-05-01T23:29:05.336Z" }, - { url = "https://files.pythonhosted.org/packages/25/8f/81dcbc2e8454b74d14881275ea45f00791052dac531a9fa8be1730d1685b/psycopg_binary-3.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:494ca54901be8cf9eb7e02c25b731f2317c378efa44f43e8f9bd0e1184ae7be4", size = 3560782, upload-time = "2026-05-01T23:29:11.967Z" }, { url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" }, { url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" }, { url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" }, @@ -1753,36 +1385,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, @@ -1828,22 +1430,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -1908,7 +1494,7 @@ name = "pytest-cov" version = "7.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coverage", extra = ["toml"] }, + { name = "coverage" }, { name = "pluggy" }, { name = "pytest" }, ] @@ -1966,25 +1552,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, @@ -2129,20 +1696,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, - { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, - { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, - { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, - { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, - { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, - { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, - { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, - { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, - { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, - { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, @@ -2173,7 +1726,6 @@ version = "1.3.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/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ @@ -2186,24 +1738,6 @@ version = "2.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, @@ -2336,18 +1870,6 @@ 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/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { 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" }, @@ -2392,34 +1914,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, - { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, - { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, - { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, - { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, - { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, - { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, - { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, - { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, - { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, - { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, - { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, - { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, - { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, - { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, - { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, - { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, - { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, - { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, - { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, @@ -2481,10 +1975,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, - { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, - { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] [[package]] @@ -2493,24 +1983,6 @@ 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/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { 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" }, @@ -2538,11 +2010,6 @@ wheels = [ { 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/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { 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" }, ] @@ -2552,28 +2019,6 @@ version = "2.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, - { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, - { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, - { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, - { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, - { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, - { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, - { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, - { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, - { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, - { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, - { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, - { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, - { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, - { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, diff --git a/docs/API.md b/docs/API.md index f6c391f3..5d68e51c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1074,7 +1074,7 @@ Stores AI provider configuration with the key encrypted at rest. ### Get Decrypted API Key -**GET** `/api/preferences/ai-config/key` +**GET** `/api/preferences/ai-config/key/reveal` Returns the decrypted API key for use in browser-direct streaming calls (OpenAI, Anthropic). The frontend calls this on each chat send; the key is never cached in localStorage. diff --git a/docs/plans/comprehensive-hardening-2026-07-04.md b/docs/plans/comprehensive-hardening-2026-07-04.md new file mode 100644 index 00000000..5d92d71f --- /dev/null +++ b/docs/plans/comprehensive-hardening-2026-07-04.md @@ -0,0 +1,135 @@ +# Comprehensive Hardening Plan (2026-07-04) + +Feature-branch: `feat/comprehensive-hardening`. + +Wave 1 research fanned out six specialist agents; this plan is the distilled action list. Original raw findings are archived at the top-level workflow output; only the merged action plan lives here. + +## Wave 0 -- shipped in `0b9d950` + +- FE `/api/preferences/ai-config/key` -> `/api/preferences/ai-config/key/reveal` (matched to BE). +- BE `rates.py` prefix `/rates` -> `/api/rates`. +- BE `exchange_rates.py` prefix `/exchange-rates` -> `/api/exchange-rates` (also picks up `no-store` cache middleware). +- BE `stock_price.py` prefix `/stock-price` -> `/api/stock-price` (same reason). +- FE call sites in `services/api/preferences.ts` updated. +- `docs/API.md` reveal-key endpoint updated. + +## Wave 2 -- structural fixes (this batch) + +Ordered by dependency, not size. Each is its own commit. + +### 2.1 Python version drift + +- `pyproject.toml`: `requires-python >=3.11` -> `>=3.13`. +- `[tool.mypy]`: `python_version = "3.12"` -> `"3.13"`. +- `.github/workflows/migrate.yml`: `python-version: '3.14'` -> `'3.13'`. +- CI already runs 3.13. Ruff target already `py313`. This aligns all four. + +### 2.2 Encryption-key split + HKDF (research finding S2/S3, bundled) + +- New env `LEDGER_SYNC_ENCRYPTION_KEY` (min 32 bytes, optional -> falls back to `jwt_secret_key` with `logger.warning`). +- `core/encryption.py` rewrite: + - Format v1 = current (no prefix, PBKDF2-100k, jwt_secret material). + - Format v2 = new (`\x02` prefix, HKDF-SHA256, `encryption_key` material, `info=b'ledger-sync/byok-api-key/v2'`). + - `decrypt_api_key` returns `tuple[str, bool]` where the bool is `needs_reencrypt`. + - Callers (`preferences_ai.get_ai_key`, `ai_chat.bedrock_chat`) upgrade in-band. +- Rollout: deploy code, set env on Vercel, wait 30 days, remove v1 branch. +- Skip Argon2id / PBKDF2 bump -- the input IS a key, not a password (see research finding S3). + +### 2.3 Refresh token rotation + `token_version` + +- Alembic migration adds `users.token_version INT NOT NULL DEFAULT 0` (empty downgrade per repo convention). +- `db/_models/user.py` model gets the column. +- JWT payload gets `tv: int`, `iat: int`. +- `verify_token` accepts `expected_tv` kwarg; when set and mismatch -> return None. +- `get_current_user` in `deps.py` compares `payload.tv == user.token_version`. +- `logout`, `reset_account`, `delete_account` bump `token_version`. +- `refresh_tokens` uses `iat`-based reuse detection (reject refresh whose iat < user.last_refresh_iat). +- Soft-accept during rollout: legacy tokens without `tv` claim treated as tv=0 until `LEDGER_SYNC_JWT_STRICT_TV=true` flipped (day 8, after refresh TTL). + +### 2.4 Per-authenticated-user rate limits + +- Add `user_limiter` in `rate_limit.py` with a `key_func` that decodes the JWT `sub` claim, falling back to IP. +- Stack `@user_limiter.limit(...)` alongside existing `@limiter.limit(...)` on authenticated endpoints (`/api/upload`, `/api/ai/bedrock/chat`). +- No migration. + +### 2.5 DB-level cascade backfill on user_id FKs + +- Alembic migration adding `ondelete="CASCADE"` to the 19 user_id FKs currently ORM-only. Backfill via `op.drop_constraint` + `op.create_foreign_key` per column. +- Empty downgrade. + +### 2.6 Robust anomaly detection + +- Replace mean+stdev with median + MAD (Iglewicz-Hoaglin modified Z-score, cutoff 3.5) in `_detect_high_expense_months`. +- Add IQR fence fallback when MAD == 0. +- Rewrite `_detect_large_transactions` with 12-month rolling window + median + `MIN_HISTORY=5`. +- Recurring detection: median for expected_amount, MAD for variance. +- User-confirmed recurring patterns bypass min_confidence gate. +- Add `severity_score` column on Anomaly for cross-type ranking. +- Add stats_snapshot JSON on Anomaly for debug. + +### 2.7 Tax detection regex expansion + +- `\btax(es)?\b` -> `\b(tax(es)?|tds|gst|cess|surcharge|advance[\s-]?tax|self[\s-]?assessment)\b` in FY summary tax_paid detection. + +### 2.8 Migrate.yml path trigger + +- Add `backend/src/ledger_sync/db/_models/**` to `paths:` so schema changes actually trigger the migrate workflow. +- Align python-version with CI (3.13). + +### 2.9 Delete broken Makefile + +- References Poetry + black; project uses uv + ruff. Delete rather than rewrite -- uv commands are documented in CLAUDE.md. + +### 2.10 Delete dead .github/renovate.json + +- Two competing renovate configs. Root `renovate.json` (shared preset) is the live one; `.github/renovate.json` is dead. + +## Wave 3 -- new features (research finding F1-F21) + +Top-8 features that pass "power-user notices, self-hosted-cheap" bar. Others deferred. + +Order: + +1. **Transaction edit / delete / notes / tags** (F3) -- unblocks rules engine feedback loop and every reconciliation feature. +2. **Rules engine** (F1) -- Actual Budget-style IF-THEN. +3. **Transfer pairs auto-dedup** (F5). +4. **Sinking funds / true expenses** (F6) -- YNAB core methodology. +5. **80C / 80D / HRA headroom tracker** (F9) -- Indian-flavor differentiator. +6. **Runway metric** (F19) -- one number for the dashboard. +7. **Merchant/payee normalization** (F16) -- ships free once Rules Engine lands. +8. **Reconciliation snapshots** (F11) -- cornerstone of trust. + +Deferred: AIS/26AS reconciliation (L effort, needs PDF parser -- promising but too big for this batch), advance tax scheduler (M, needs UI), custom dashboard widgets (M, react-grid-layout dep), custom report builder (L), retirement projector (M, overlaps with FIRE calc), refund detection (S, do later), category forecast (S, do later). + +## Wave 4 -- stack upgrades (research finding S1-S27, all "S" effort) + +Dependency order: patches first (safe), then behind-latest packages (medium), then one-off breaking change (bcrypt 5.0 last). + +- **Patches**: alembic 1.18.4->.5, fastapi 0.138->.139, pytest 9.1.0->.1, framer-motion 12.42.0->.2, recharts 3.9.0->.2, vite 8.1.0->.3, tailwindcss 4.3.1->.2, PyJWT 2.12->2.13, openpyxl 3.1.0->.5, python-multipart 0.0.22->0.0.32, boto3 1.43.0->.40, httpx 0.28.0->.1, ruff 0.15.0->.20, pydantic 2.13.0->.4. +- **Behind**: pydantic-settings 2.2->2.14, typer 0.21->0.26, cryptography 48->49, pip-audit 2.7->2.10, rich 14->15, lucide-react 1.21->1.23. +- **Breaking**: bcrypt 4.0.1->5.0.0 (rejects >72-byte passwords with ValueError). Since we're OAuth-only, bcrypt is only used by (currently unused) `passwords.py`. Safe to bump; can also delete `passwords.py` in same commit since it's confirmed dead code. + +Skip: pandas 3.0 (ecosystem not ready), everything already-current. + +## Wave 5 -- docs, drift, hygiene + +- HANDBOOK.md v2.17 -> current (add /overview page, Light theme mention). +- Page count reconciled everywhere (27 actual). +- DATABASE.md + DEVELOPMENT.md: "Define model in `db/models.py`" -> "Define model under `db/_models/`" (per new-migration skill). +- CLAUDE.md AI tool list refreshed (drop `get_savings_rate`, `get_top_merchants`, `get_transfer_flows`, `get_investment_holdings`; add `get_tax_summary`, `get_cash_flow`, `list_anomalies`, `get_preferences_summary`). +- Add `SECURITY.md` (report to sagargupta.online contact + rotation policy) + `CONTRIBUTING.md`. +- Fix duplicate `# CLAUDE.md` heading. +- Add `timeout-minutes: 20` to all workflow jobs. +- Delete dead endpoints (`meta.*` if unused, `analytics.wrapped`, `calculations.{insights, top-categories, daily-net-worth}` -- only if grep confirms no FE consumer). + +## Wave 6 (post-merge, optional) -- AI tool + system-prompt hardening + +From research finding A1-A25. + +- Port `frontend/src/lib/tax-config/` + `taxCalculator.ts` to a Python `core/tax_engine.py` (single source of truth for both LLM tools and eventual server-side tax page). +- Add tools: `compare_tax_regimes`, `project_tax_liability`, `find_duplicate_subscriptions`, `get_subscription_summary`, `get_top_merchants`, `compare_periods`, `get_fire_projection`, `get_savings_rate_trend`, `compute_hra_exemption`, `get_dashboard_snapshot`. +- Update system prompt: FY vocabulary, tool preference order, refuse-to-hand-calculate, currency clarification. + +--- + +*This plan is derived from six parallel research tracks completed 2026-07-04. Reference file: session transcript, task w9r2tm6m9.* diff --git a/frontend/src/components/analytics/CategoryBreakdown.tsx b/frontend/src/components/analytics/CategoryBreakdown.tsx index da9c0739..b8022d4a 100644 --- a/frontend/src/components/analytics/CategoryBreakdown.tsx +++ b/frontend/src/components/analytics/CategoryBreakdown.tsx @@ -9,6 +9,7 @@ import { CHART_COLORS } from '@/constants/chartColors' import EmptyState from '@/components/shared/EmptyState' import { ChartSkeleton } from '@/components/shared/LoadingSkeleton' import Sparkline from '@/components/shared/Sparkline' +import { Money } from '@/components/ui' import { averagePerActiveMonth, buildCategories, trailingMonthKeys } from './categoryBreakdownUtils' @@ -196,9 +197,8 @@ export default function CategoryBreakdown({ {cat.percent.toFixed(1)}% - - {formatCurrency(cat.total)} - + + {/* Expand chevron */} {hasSubcategories && ( @@ -281,9 +281,7 @@ export default function CategoryBreakdown({ {sub.percent.toFixed(0)}% - - {formatCurrency(sub.amount)} - + ))} diff --git a/frontend/src/components/analytics/period-comparison/PeriodSelectors.tsx b/frontend/src/components/analytics/period-comparison/PeriodSelectors.tsx index 4e331f15..cea1d0a4 100644 --- a/frontend/src/components/analytics/period-comparison/PeriodSelectors.tsx +++ b/frontend/src/components/analytics/period-comparison/PeriodSelectors.tsx @@ -49,7 +49,7 @@ export function PeriodSelectors(props: Readonly) { className="px-4 py-2 text-sm font-medium rounded-lg transition-colors" style={{ backgroundColor: compareMode === 'months' ? rawColors.app.blue : 'transparent', - color: compareMode === 'months' ? '#fff' : rawColors.text.secondary, + color: compareMode === 'months' ? rawColors.onAccent : rawColors.text.secondary, }} > Monthly @@ -62,7 +62,7 @@ export function PeriodSelectors(props: Readonly) { className="px-4 py-2 text-sm font-medium rounded-lg transition-colors" style={{ backgroundColor: compareMode === 'years' ? rawColors.app.blue : 'transparent', - color: compareMode === 'years' ? '#fff' : rawColors.text.secondary, + color: compareMode === 'years' ? rawColors.onAccent : rawColors.text.secondary, }} > Yearly diff --git a/frontend/src/components/layout/Sidebar/navConfig.ts b/frontend/src/components/layout/Sidebar/navConfig.ts index a87fff0c..93321a35 100644 --- a/frontend/src/components/layout/Sidebar/navConfig.ts +++ b/frontend/src/components/layout/Sidebar/navConfig.ts @@ -91,7 +91,7 @@ export const navigationSections: NavSection[] = [ { title: 'Planning', items: [ - { path: ROUTES.BUDGETS, label: 'Budget Manager', icon: Wallet2 }, + { path: ROUTES.BUDGETS, label: 'Budget Rule', icon: Wallet2 }, { path: ROUTES.GOALS, label: 'Financial Goals', icon: Goal }, { path: ROUTES.FIRE_CALCULATOR, label: 'FIRE Calculator', icon: Flame }, { path: ROUTES.ANOMALIES, label: 'Anomaly Review', icon: AlertTriangle }, diff --git a/frontend/src/components/shared/CommandPalette.tsx b/frontend/src/components/shared/CommandPalette.tsx index 3a584491..d9f8f5c4 100644 --- a/frontend/src/components/shared/CommandPalette.tsx +++ b/frontend/src/components/shared/CommandPalette.tsx @@ -180,7 +180,9 @@ export default function CommandPalette() { ` codifies the canonical amount-cell rule: + * - `tabular-nums` -- digits align vertically + * - `text-right` -- always right-aligned + * - `whitespace-nowrap` -- never truncates mid-number + * - `shrink-0` -- flex parents can't compress it + * - default `font-medium` weight (override via `bold`) + * + * Consumers pick the width via a preset (`sm` | `md` | `lg`) or a raw class + * for the rare custom case. No fixed default width -- the caller knows its + * layout, and forcing one would break the many free-flowing usages. + * + * @example + * + * + * + */ +type Width = 'sm' | 'md' | 'lg' | 'xl' + +const WIDTH_CLASS: Record = { + sm: 'w-20 sm:w-24', + md: 'w-24 sm:w-28', + lg: 'w-28 sm:w-32', + xl: 'w-32 sm:w-40', +} + +interface MoneyProps { + readonly value: number + /** Fixed-width preset so a flex parent can't squeeze it. Omit for free flow. */ + readonly width?: Width + /** Bold weight (font-semibold) instead of the default font-medium. */ + readonly bold?: boolean + /** Render in muted-foreground (e.g. secondary totals, "Other" rollup rows). */ + readonly muted?: boolean + /** Extra classes appended after the canonical set. */ + readonly className?: string + /** Override the formatter (defaults to `formatCurrency` with user's currency prefs). */ + readonly formatter?: (value: number) => string + /** Optional aria-label override; defaults to the formatted value itself. */ + readonly ariaLabel?: string +} + +const Money = forwardRef(function Money( + { value, width, bold = false, muted = false, className, formatter = formatCurrency, ariaLabel }, + ref, +) { + const formatted = formatter(value) + return ( + + {formatted} + + ) +}) + +export default Money diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 08508614..7023083e 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -7,6 +7,7 @@ export { default as ConfirmDialog } from './ConfirmDialog' export { default as DataTable } from './DataTable' export type { DataTableColumn, DataTableProps } from './DataTable' export { default as Input, Select } from './Input' +export { default as Money } from './Money' export { default as PageContainer } from './PageContainer' export { default as PageHeader } from './PageHeader' export { default as Spinner } from './Spinner' diff --git a/frontend/src/constants/colors.ts b/frontend/src/constants/colors.ts index e179d93f..9a3edfdb 100644 --- a/frontend/src/constants/colors.ts +++ b/frontend/src/constants/colors.ts @@ -129,6 +129,11 @@ function buildRawColors() { tertiary: r('--color-text-tertiary', '#7c7c80'), quaternary: r('--color-text-quaternary', '#48484a'), }, + // Foreground for accent-colored surfaces (blue/green pill fills, primary + // buttons). Kept as a dedicated token so it stays white even in light + // theme -- the accent background is bright enough that white text has + // AA contrast in both themes. + onAccent: r('--color-on-accent', '#ffffff'), // Chart-only neutrals/surfaces. CSS var() can't be used in SVG presentation // attributes, so these resolve the --chart-* tokens to concrete strings for // Recharts. Fallbacks are the historical dark values so SSR/no-DOM is safe. diff --git a/frontend/src/hooks/api/useAnalyticsV2.ts b/frontend/src/hooks/api/useAnalyticsV2.ts index 04e334f2..d07ca73b 100644 --- a/frontend/src/hooks/api/useAnalyticsV2.ts +++ b/frontend/src/hooks/api/useAnalyticsV2.ts @@ -23,6 +23,7 @@ import type { MonthlySummary, NetWorthSnapshot, RecurringTransaction, + SpendingRuleResponse, TransferFlow, } from '@/services/api/analyticsV2' @@ -50,6 +51,8 @@ export const analyticsV2Keys = { [...analyticsV2Keys.all, 'budgets', filters?.active_only] as const, goals: (filters?: { goal_type?: string; include_achieved?: boolean }) => [...analyticsV2Keys.all, 'goals', filters?.goal_type, filters?.include_achieved] as const, + spendingRule: (filters?: { start_date?: string; end_date?: string }) => + [...analyticsV2Keys.all, 'spending-rule', filters?.start_date, filters?.end_date] as const, } // Daily Summaries @@ -292,6 +295,18 @@ export function useCreateGoal() { }) } +// 50/30/20 spending-rule aggregation. Cached per date-range params. Unlike +// the other v2 endpoints, this one is a live query rather than a rollup read, +// so it re-runs against current preferences -- fine because it's cheap and +// runs only when the user visits /budgets. +export function useSpendingRule(params?: { start_date?: string; end_date?: string }) { + return useQuery({ + queryKey: analyticsV2Keys.spendingRule(params), + queryFn: () => analyticsV2Service.getSpendingRule(params), + staleTime: STABLE_STALE_TIME, + }) +} + // Re-export types for convenience export type { Anomaly, @@ -305,5 +320,8 @@ export type { MonthlySummary, NetWorthSnapshot, RecurringTransaction, + SpendingRuleBucket, + SpendingRuleCategoryRow, + SpendingRuleResponse, TransferFlow, } from '@/services/api/analyticsV2' diff --git a/frontend/src/lib/__tests__/dateUtils.test.ts b/frontend/src/lib/__tests__/dateUtils.test.ts index 02b489b2..48f14a00 100644 --- a/frontend/src/lib/__tests__/dateUtils.test.ts +++ b/frontend/src/lib/__tests__/dateUtils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { formatMonthKey, toLocalDateKey } from '../dateUtils' +import { capEndDateAtToday, capSeriesToToday, formatMonthKey, toLocalDateKey } from '../dateUtils' /** * These guard the timezone-stable date helpers. The bug class they replace: @@ -49,3 +49,81 @@ describe('formatMonthKey', () => { expect(formatMonthKey('not-a-date')).toBe('not-a-date') }) }) + +describe('capEndDateAtToday', () => { + const today = toLocalDateKey(new Date()) + const yesterday = toLocalDateKey(new Date(Date.now() - 24 * 60 * 60 * 1000)) + const tomorrow = toLocalDateKey(new Date(Date.now() + 24 * 60 * 60 * 1000)) + + it('caps a future end_date at today', () => { + const result = capEndDateAtToday({ start_date: '2026-01-01', end_date: tomorrow }) + expect(result.end_date).toBe(today) + expect(result.start_date).toBe('2026-01-01') + }) + + it('leaves past end_date untouched', () => { + const range = { start_date: '2020-01-01', end_date: yesterday } + expect(capEndDateAtToday(range)).toBe(range) + }) + + it('preserves null end_date (all_time)', () => { + const range = { start_date: null, end_date: null } + expect(capEndDateAtToday(range)).toBe(range) + }) + + it('does not mutate the input when capping', () => { + const range = { start_date: '2026-01-01', end_date: '2999-12-31' } + capEndDateAtToday(range) + expect(range.end_date).toBe('2999-12-31') + }) +}) + +describe('capSeriesToToday', () => { + const today = toLocalDateKey(new Date()) + const currentMonth = today.slice(0, 7) + + it('drops future day-keyed rows and keeps today', () => { + const rows = [ + { date: '2020-01-01', v: 1 }, + { date: today, v: 2 }, + { date: '2999-12-31', v: 3 } + ] + expect(capSeriesToToday(rows, 'date')).toEqual([ + { date: '2020-01-01', v: 1 }, + { date: today, v: 2 } + ]) + }) + + it('drops future month-keyed rows and keeps current month', () => { + const rows = [ + { month: '2020-06', v: 1 }, + { month: currentMonth, v: 2 }, + { month: '2999-12', v: 3 } + ] + expect(capSeriesToToday(rows, 'month')).toEqual([ + { month: '2020-06', v: 1 }, + { month: currentMonth, v: 2 } + ]) + }) + + it('handles Date-valued keys', () => { + const rows = [ + { d: new Date(2020, 0, 1), v: 1 }, + { d: new Date(2999, 11, 31), v: 2 } + ] + expect(capSeriesToToday(rows, 'd')).toEqual([rows[0]]) + }) + + it('returns empty array unchanged', () => { + expect(capSeriesToToday([] as Array<{ date: string }>, 'date')).toEqual([]) + }) + + it('preserves original order (does not sort)', () => { + const rows = [ + { date: '2022-05-01', v: 1 }, + { date: '2020-01-01', v: 2 }, + { date: today, v: 3 } + ] + expect(capSeriesToToday(rows, 'date').map((r) => r.v)).toEqual([1, 2, 3]) + }) +}) diff --git a/frontend/src/lib/dateUtils.ts b/frontend/src/lib/dateUtils.ts index f4f01730..5c65fa45 100644 --- a/frontend/src/lib/dateUtils.ts +++ b/frontend/src/lib/dateUtils.ts @@ -187,27 +187,65 @@ export const getAnalyticsDateRange = ({ case 'all_time': return { start_date: null, end_date: null } case 'yearly': - return { + return capEndDateAtToday({ start_date: `${currentYear}-01-01`, end_date: `${currentYear}-12-31` - } + }) case 'fy': { const fyRange = getFYDateRange(currentFY, fiscalYearStartMonth) - return { + return capEndDateAtToday({ start_date: fyRange.start, end_date: fyRange.end - } + }) } case 'monthly': { const year = Number.parseInt(currentMonth.substring(0, 4)) const month = Number.parseInt(currentMonth.substring(5, 7)) const lastDay = new Date(year, month, 0).getDate() - return { + return capEndDateAtToday({ start_date: `${currentMonth}-01`, end_date: `${currentMonth}-${lastDay}` - } + }) } default: return { start_date: null, end_date: null } } } + +/** + * Cap `end_date` at today (local `YYYY-MM-DD`) so future-dated ranges (a monthly + * or FY window whose end lies past "now") don't drag divisor math (avg/day) + * into the future. Null `end_date` (all-time) is preserved. + * + * ISO `YYYY-MM-DD` compares lexicographically, so no Date parsing needed. + * Immutable: returns the input untouched when no cap is required. + */ +export const capEndDateAtToday = ( + range: T +): T => { + const today = toLocalDateKey(new Date()) + if (range.end_date && range.end_date > today) return { ...range, end_date: today } + return range +} + +/** + * Drop rows whose date key sits in the future relative to today. Generic over + * row shape and key granularity: + * - month-keyed (`YYYY-MM`) rows are kept when `row[key] <= current YYYY-MM` + * - day-keyed (`YYYY-MM-DD`) rows are kept when `row[key] <= today` + * + * String comparison is lexicographic-safe for both formats. Accepts `Date` + * values by normalising them via `toLocalDateKey` and comparing against today + * as a full `YYYY-MM-DD`. + */ +export const capSeriesToToday = (rows: readonly T[], key: keyof T): T[] => { + const today = toLocalDateKey(new Date()) + const currentMonth = today.slice(0, 7) + return rows.filter((row) => { + const raw = row[key] + if (raw instanceof Date) return toLocalDateKey(raw) <= today + if (typeof raw !== 'string') return true + const cutoff = raw.length === 7 ? currentMonth : today + return raw <= cutoff + }) +} diff --git a/frontend/src/pages/AnomalyReviewPage.tsx b/frontend/src/pages/AnomalyReviewPage.tsx index d7f8339b..055814a5 100644 --- a/frontend/src/pages/AnomalyReviewPage.tsx +++ b/frontend/src/pages/AnomalyReviewPage.tsx @@ -5,7 +5,7 @@ import { TrendingUp, HelpCircle, ArrowRightLeft, AlertTriangle, AlertCircle, Inf import { Link } from 'react-router-dom' import { toast } from 'sonner' -import { PageHeader, StatCard, CollapsibleSection } from '@/components/ui' +import { PageContainer, PageHeader, StatCard, CollapsibleSection } from '@/components/ui' import { ROUTES } from '@/constants' import { useAnomalies, useReviewAnomaly } from '@/hooks/api/useAnalyticsV2' import type { Anomaly } from '@/hooks/api/useAnalyticsV2' @@ -173,8 +173,7 @@ export default function AnomalyReviewPage() { } return ( -
-
+ )} -
-
+ ) } diff --git a/frontend/src/pages/FIRECalculatorPage.tsx b/frontend/src/pages/FIRECalculatorPage.tsx index 313dc57b..74320cbe 100644 --- a/frontend/src/pages/FIRECalculatorPage.tsx +++ b/frontend/src/pages/FIRECalculatorPage.tsx @@ -12,7 +12,7 @@ import { rawColors } from '@/constants/colors' import MetricCard from '@/components/shared/MetricCard' import StandardAreaChart from '@/components/analytics/StandardAreaChart' import StandardBarChart from '@/components/analytics/StandardBarChart' -import { PageHeader, currencyTooltipFormatter } from '@/components/ui' +import { PageContainer, PageHeader, currencyTooltipFormatter } from '@/components/ui' import { formatCurrencyShort } from '@/lib/formatters' function SliderInput({ id, label, value, min, max, step, unit, valueText, onChange }: Readonly<{ @@ -106,8 +106,7 @@ export default function FIRECalculatorPage() { if (isLoading) return return ( -
-
+ )} -
-
+ ) } diff --git a/frontend/src/pages/MorePage.tsx b/frontend/src/pages/MorePage.tsx index 59b27a27..24a310f3 100644 --- a/frontend/src/pages/MorePage.tsx +++ b/frontend/src/pages/MorePage.tsx @@ -25,7 +25,7 @@ import { import type { LucideIcon } from 'lucide-react' import { ROUTES } from '@/constants' -import { PageHeader } from '@/components/ui' +import { PageContainer, PageHeader } from '@/components/ui' import { useLogout } from '@/hooks/api/useAuth' interface MoreItem { @@ -133,8 +133,7 @@ export default function MorePage() { } return ( -
-
+ {SECTIONS.map((section, sIdx) => ( @@ -165,7 +164,6 @@ export default function MorePage() { Sign out -
-
+ ) } diff --git a/frontend/src/pages/TransactionsPage.tsx b/frontend/src/pages/TransactionsPage.tsx index 5a845fff..7b1c9d02 100644 --- a/frontend/src/pages/TransactionsPage.tsx +++ b/frontend/src/pages/TransactionsPage.tsx @@ -6,7 +6,7 @@ import { Download, Receipt } from 'lucide-react' import type { SortingState } from '@tanstack/react-table' import { toast } from 'sonner' -import { PageHeader } from '@/components/ui' +import { PageContainer, PageHeader } from '@/components/ui' import TransactionTable from '@/components/transactions/TransactionTable' import TransactionFilters, { type FilterValues } from '@/components/transactions/TransactionFilters' import Pagination from '@/components/transactions/Pagination' @@ -119,8 +119,7 @@ export default function TransactionsPage() { } return ( -
-
+ {/* Header */} )} -
-
+ ) } diff --git a/frontend/src/pages/budget/BudgetPage.tsx b/frontend/src/pages/budget/BudgetPage.tsx index 76f2ceac..95f447bb 100644 --- a/frontend/src/pages/budget/BudgetPage.tsx +++ b/frontend/src/pages/budget/BudgetPage.tsx @@ -1,40 +1,60 @@ +import { useMemo, useState } from 'react' + import { motion } from 'framer-motion' -import { - AlertTriangle, - BarChart3, - CheckCircle, - PiggyBank, - Plus, - Target, - TrendingDown, -} from 'lucide-react' +import { AlertTriangle, PiggyBank, ShoppingBag, Target } from 'lucide-react' import EmptyState from '@/components/shared/EmptyState' +import LoadingSkeleton from '@/components/shared/LoadingSkeleton' import { PageContainer, PageHeader } from '@/components/ui' import { fadeUpItem, staggerContainer } from '@/constants/animations' -import { rawColors } from '@/constants/colors' +import { useDataDateRange } from '@/hooks/api/useAnalytics' +import { useSpendingRule } from '@/hooks/api/useAnalyticsV2' +import type { SpendingBucket, SpendingRuleResponse } from '@/services/api/analyticsV2' import { formatCurrency } from '@/lib/formatters' -import StatCard from '@/pages/year-in-review/components/StatCard' -import { AddBudgetForm } from './components/AddBudgetForm' -import BudgetDefaultsPanel from './components/BudgetDefaultsPanel' -import { BudgetCharts } from './components/BudgetCharts' -import { BudgetRowItem } from './components/BudgetRowItem' -import { useBudget } from './useBudget' +import { BucketCard } from './components/BucketCard' +import { CategoryTable } from './components/CategoryTable' +import { PeriodPicker, type PresetPeriod } from './components/PeriodPicker' +import { toPeriodRange } from './budgetUtils' +/** + * /budgets — the 50/30/20 Budget Rule page. + * + * Header cards show Needs / Wants / Savings actuals vs targets from + * `user_preferences.{needs,wants,savings}_target_percent`. The table below + * breaks down every category the user spent on in the selected period, + * grouped by bucket, with monthly-average calculated over the period length. + * + * The visual model follows Elizabeth Warren's *All Your Worth*: savings is + * income - expenses (the header savings card), while the table's Savings rows + * show what actually landed in investment accounts (SIP, PPF, EPF, etc.). + */ export default function BudgetPage() { - const m = useBudget() + const [period, setPeriod] = useState('last_12_months') + const [customStart, setCustomStart] = useState('') + const [customEnd, setCustomEnd] = useState('') + const { minDate, maxDate } = useDataDateRange() + + const range = useMemo( + () => toPeriodRange(period, { customStart, customEnd, minDate, maxDate }), + [period, customStart, customEnd, minDate, maxDate], + ) + + const { data, isLoading, isError } = useSpendingRule({ + start_date: range.start, + end_date: range.end, + }) - if (m.isError) { + if (isError) { return ( @@ -45,226 +65,155 @@ export default function BudgetPage() { return ( -
- {( - [ - ['category', 'Category'], - ['subcategory', 'Subcategory'], - ] as const - ).map(([val, label]) => ( - m.setViewMode(val)} - className={`relative px-3 py-2.5 sm:py-1.5 rounded-lg text-sm font-medium transition-colors ${ - m.viewMode === val - ? 'text-foreground' - : 'text-muted-foreground hover:text-foreground hover:bg-[var(--overlay-5)]' - }`} - whileTap={{ scale: 0.97 }} - > - {m.viewMode === val && ( - - )} - {label} - - ))} -
- m.setIsAdding(true)} - whileTap={{ scale: 0.97 }} - className="flex items-center gap-2 px-4 py-2.5 sm:py-2 rounded-xl text-sm font-medium text-foreground transition-colors" - style={{ - background: `linear-gradient(135deg, ${rawColors.app.green}, ${rawColors.app.teal})`, - }} - > - Add Budget - - + { + setCustomStart(s) + setCustomEnd(e) + }} + minDate={minDate} + maxDate={maxDate} + /> } /> - - - {m.summary.count > 0 && ( - - - - - - m.summary.totalBudget - ? rawColors.app.red - : rawColors.app.green - } - /> - - - - - - - - - )} + {renderBody(isLoading, data)} +
+ ) +} - +
+ + + +
+ + + ) + } + // Defensive: some upstream (demo adapter, cached wrong shape, etc.) could + // feed us an object without the expected keys. Fail soft rather than crash. + if (!data.period || !data.buckets) { + return ( + + ) + } + return +} - {m.filteredRows.length > 0 ? ( - <> - +function BudgetRuleContent({ data }: { readonly data: SpendingRuleResponse }) { + const cards: Array<{ + readonly bucket: SpendingBucket + readonly title: string + readonly description: string + readonly icon: typeof Target + readonly kind: 'cap' | 'floor' + }> = [ + { + bucket: 'needs', + title: 'Needs', + description: 'Housing, Healthcare, Food, etc.', + icon: Target, + kind: 'cap', + }, + { + bucket: 'wants', + title: 'Wants', + description: 'Entertainment, Shopping, etc.', + icon: ShoppingBag, + kind: 'cap', + }, + { + bucket: 'savings', + title: 'Savings', + description: 'Income minus Expenses', + icon: PiggyBank, + kind: 'floor', + }, + ] -
- {m.filteredRows.map((row) => { - const key = row.subcategory ? `${row.category}::${row.subcategory}` : row.category - return ( - m.setEditKey(key)} - onCancelEdit={() => m.setEditKey(null)} - onSave={(limit, period) => { - m.setBudget(key, limit, period) - m.setEditKey(null) - }} - onDelete={() => m.removeBudget(key)} - /> - ) - })} -
- - ) : ( - - -

No budgets set yet

-

- Set spending limits for your categories to start tracking. We'll suggest limits based - on your spending patterns. -

- -
- )} + const hasIncome = data.income_total > 0 - {m.availableCategories.length > 0 && m.filteredRows.length > 0 && ( - -
- -

Suggested Budgets

- Based on current spending + return ( + + {/* Header stat: income + expenses + period summary */} + +
+
+ Period: {formatDateShort(data.period.start)}{' '} + → {formatDateShort(data.period.end)}{' '} + ({data.period.months} {data.period.months === 1 ? 'month' : 'months'})
-
- {(() => { - // Suggestions reflect the selected budget period: monthly uses - // current-month spend (/mo), yearly uses fiscal-year spend (/yr). - const isYearly = m.budgetPeriod === 'yearly' - const periodSuffix = isYearly ? '/yr' : '/mo' - const spendFor = (c: string) => - isYearly - ? m.spendingData.byCategoryYearly[c] || - m.spendingData.bySubcategoryYearly[c] || - 0 - : m.spendingData.byCategory[c] || m.spendingData.bySubcategory[c] || 0 - return m.availableCategories - .filter((c) => spendFor(c) > 500) - .slice(0, 8) - .map((cat) => { - const spent = spendFor(cat) - const displayName = cat.includes('::') ? cat.split('::')[1] : cat - return ( - m.handleQuickAdd(cat, spent)} - whileTap={{ scale: 0.95 }} - className="px-3 py-2 sm:py-1.5 rounded-full text-xs font-medium transition-colors hover:scale-105" - style={{ - backgroundColor: `${rawColors.app.green}15`, - color: rawColors.app.green, - }} - > - + {displayName} ({formatCurrency(spent)} - {periodSuffix}) - - ) - }) - })()} +
+ Income: {formatCurrency(data.income_total)} + {' · '} + Expenses: {formatCurrency(data.expense_total)}
+
+ + + {!hasIncome && ( + + )} - + + {/* Three-card row */} + + {cards.map((card) => ( + + ))} + + + {/* Category breakdown table */} + + + + ) } + +function formatDateShort(iso: string): string { + const d = new Date(iso) + return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) +} diff --git a/frontend/src/pages/budget/budgetUtils.ts b/frontend/src/pages/budget/budgetUtils.ts index a70e0aa1..99003ac9 100644 --- a/frontend/src/pages/budget/budgetUtils.ts +++ b/frontend/src/pages/budget/budgetUtils.ts @@ -1,37 +1,75 @@ -import { rawColors } from '@/constants/colors' +import type { PresetPeriod } from './components/PeriodPicker' -import type { BudgetRow } from './types' - -export const STATUS_CONFIG = { - safe: { - color: rawColors.app.green, - bg: 'bg-app-green/10', - border: 'border-app-green/20', - text: 'text-app-green', - }, - warning: { - color: rawColors.app.yellow, - bg: 'bg-app-yellow/10', - border: 'border-app-yellow/20', - text: 'text-app-yellow', - }, - danger: { - color: rawColors.app.orange, - bg: 'bg-app-orange/10', - border: 'border-app-orange/20', - text: 'text-app-orange', +/** + * Map a preset period to an ISO date-range. All ranges are inclusive of the + * end date and use browser-local dates rather than UTC -- consistent with how + * users think about a month ("June" = the whole calendar month, not a + * UTC-shifted window). + * + * For preset='custom', pass customStart/customEnd (YYYY-MM-DD strings from + * the date inputs). For preset='all_time', the caller passes minDate/maxDate + * from useDataDateRange so we don't have to invent an artificial floor. + */ +export function toPeriodRange( + period: PresetPeriod, + opts?: { + customStart?: string + customEnd?: string + minDate?: string + maxDate?: string }, - exceeded: { - color: rawColors.app.red, - bg: 'bg-app-red/10', - border: 'border-app-red/20', - text: 'text-app-red', - }, -} as const +): { start: string; end: string } { + const now = new Date() + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59) // last day of current month + let start: Date + + switch (period) { + case 'last_3_months': + start = new Date(now.getFullYear(), now.getMonth() - 2, 1) + break + case 'last_6_months': + start = new Date(now.getFullYear(), now.getMonth() - 5, 1) + break + case 'last_2_years': + start = new Date(now.getFullYear() - 2, now.getMonth() + 1, 1) + break + case 'last_5_years': + start = new Date(now.getFullYear() - 5, now.getMonth() + 1, 1) + break + case 'all_time': + // Fall back to a wide window when the caller hasn't provided real bounds + // yet (data-date-range still loading). Backend will clamp. + return { + start: opts?.minDate ?? new Date(2000, 0, 1).toISOString(), + end: opts?.maxDate ?? end.toISOString(), + } + case 'this_fy': + // Indian FY: April to March. If current month < April, we're in the FY + // that started in the previous calendar year. + start = + now.getMonth() < 3 + ? new Date(now.getFullYear() - 1, 3, 1) + : new Date(now.getFullYear(), 3, 1) + break + case 'custom': + // Guard: if custom fields aren't set yet, fall back to Last 12 mo. + // Caller UI prevents applying 'custom' without both dates, but the + // fallback stops the query from returning a broken 422 in edge cases. + if (opts?.customStart && opts?.customEnd) { + // customStart/customEnd are YYYY-MM-DD; append time so the end-of-day + // is included (matches the "last day 23:59:59" convention above). + return { + start: new Date(opts.customStart + 'T00:00:00').toISOString(), + end: new Date(opts.customEnd + 'T23:59:59').toISOString(), + } + } + start = new Date(now.getFullYear(), now.getMonth() - 11, 1) + break + case 'last_12_months': + default: + start = new Date(now.getFullYear(), now.getMonth() - 11, 1) + break + } -export function buildStatus(pct: number, alertThreshold: number): BudgetRow['status'] { - if (pct >= 100) return 'exceeded' - if (pct >= alertThreshold) return 'danger' - if (pct >= alertThreshold * 0.75) return 'warning' - return 'safe' + return { start: start.toISOString(), end: end.toISOString() } } diff --git a/frontend/src/pages/budget/components/AddBudgetForm.tsx b/frontend/src/pages/budget/components/AddBudgetForm.tsx deleted file mode 100644 index bf454a0d..00000000 --- a/frontend/src/pages/budget/components/AddBudgetForm.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { AnimatePresence, motion } from 'framer-motion' -import { X } from 'lucide-react' - -import { rawColors } from '@/constants/colors' - -import type { BudgetPeriod, ViewMode } from '../types' - -interface AddBudgetFormProps { - isAdding: boolean - setIsAdding: (v: boolean) => void - viewMode: ViewMode - formCategory: string - setFormCategory: (v: string) => void - formSubcategory: string - setFormSubcategory: (v: string) => void - formLimit: string - setFormLimit: (v: string) => void - budgetPeriod: BudgetPeriod - setBudgetPeriod: (v: BudgetPeriod) => void - availableCategories: string[] - allCategories: string[] - subcategoriesForCategory: Record - onAdd: () => void -} - -export function AddBudgetForm(props: Readonly) { - const { - isAdding, - setIsAdding, - viewMode, - formCategory, - setFormCategory, - formSubcategory, - setFormSubcategory, - formLimit, - setFormLimit, - budgetPeriod, - setBudgetPeriod, - availableCategories, - allCategories, - subcategoriesForCategory, - onAdd, - } = props - - return ( - - {isAdding && ( - -
-

New Budget

- -
-
- {viewMode === 'category' ? ( -
- - -
- ) : ( - <> -
- - -
-
- - -
- - )} -
- - setFormLimit(e.target.value)} - placeholder="Amount" - className="w-full px-3 py-2.5 rounded-lg bg-surface-3 backdrop-blur-xl border border-border text-sm text-foreground placeholder:text-text-quaternary" - /> -
-
- - -
- -
-
- )} -
- ) -} diff --git a/frontend/src/pages/budget/components/BucketCard.tsx b/frontend/src/pages/budget/components/BucketCard.tsx new file mode 100644 index 00000000..dbaaf390 --- /dev/null +++ b/frontend/src/pages/budget/components/BucketCard.tsx @@ -0,0 +1,138 @@ +import type { LucideIcon } from 'lucide-react' +import { motion } from 'framer-motion' + +import ProgressBar from '@/components/shared/ProgressBar' +import { formatCurrency } from '@/lib/formatters' +import type { SpendingBucket } from '@/services/api/analyticsV2' + +/** + * One of the three big header cards on the /budgets page (Needs, Wants, Savings). + * + * Design: title + description + big amount + progress bar showing current% vs + * target with a color that reflects on-target status. Score-delta line at the + * bottom mirrors the user's spec ("−5 pts vs target"). + */ +interface Props { + readonly bucket: SpendingBucket + readonly title: string + readonly description: string + readonly icon: LucideIcon + /** 'cap' = target is a maximum (Needs, Wants). 'floor' = target is a minimum (Savings). */ + readonly kind: 'cap' | 'floor' + readonly amount: number + readonly pctOfIncome: number + readonly target: number + /** Signed: positive = on the good side of target. */ + readonly scoreDelta: number + readonly hasIncome: boolean +} + +/** + * Bucket color: pass/warn/fail based on the score delta. + * - Green: on-target (Needs<=50, Wants<=30, Savings>=20). + * - Amber: within 5 points of missing the target (leaning wrong). + * - Red: clearly off-target (5+ points wrong side). + */ +function statusFor(scoreDelta: number, hasIncome: boolean): 'good' | 'warn' | 'bad' { + if (!hasIncome) return 'warn' + if (scoreDelta >= 0) return 'good' + if (scoreDelta > -5) return 'warn' + return 'bad' +} + +const STATUS_COLORS = { + good: 'text-app-green', + warn: 'text-app-orange', + bad: 'text-app-red', +} as const + +const PROGRESS_TINTS = { + needs: 'var(--color-app-blue)', + wants: 'var(--color-app-orange)', + savings: 'var(--color-app-green)', +} as const + +export function BucketCard({ + bucket, + title, + description, + icon: Icon, + kind, + amount, + pctOfIncome, + target, + scoreDelta, + hasIncome, +}: Props) { + const status = statusFor(scoreDelta, hasIncome) + + // Cap-kind cards fill from 0 -> target -> over (bar can exceed 100%). + // Floor-kind card fills from 0 -> target and stops (savings above 100% is + // still 100% full visually; the score-delta line shows the surplus). + const progressPct = kind === 'cap' ? pctOfIncome : Math.min(pctOfIncome, target) + const isOverCap = kind === 'cap' && pctOfIncome > target + + const targetLabel = + kind === 'cap' ? `Target: ≤${target}% of income` : `Target: ≥${target}% of income` + + const deltaSign = scoreDelta > 0 ? '+' : '' + const deltaLabel = + scoreDelta === 0 + ? 'on target' + : `${deltaSign}${scoreDelta.toFixed(0)} pts vs target` + + return ( + + {/* Top row: icon + title + description */} +
+ +
+
+

{title}

+ ({target}%) +
+

{description}

+
+
+ + {/* Big amount -- KPI hero scale, matches MetricCard hero */} +
+ {formatCurrency(amount)} +
+ + {/* Progress bar with % label */} +
+
+ Current + + {pctOfIncome.toFixed(1)}% + +
+ +
+ + {/* Footer: target + delta */} +
+ {targetLabel} · {deltaLabel} +
+
+ ) +} diff --git a/frontend/src/pages/budget/components/BudgetCharts.tsx b/frontend/src/pages/budget/components/BudgetCharts.tsx deleted file mode 100644 index cd5fe95c..00000000 --- a/frontend/src/pages/budget/components/BudgetCharts.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { useNavigate } from 'react-router-dom' - -import { motion } from 'framer-motion' -import { Area, AreaChart, CartesianGrid, Line, Tooltip, XAxis, YAxis } from 'recharts' - -import StandardBarChart from '@/components/analytics/StandardBarChart' -import { useIsMobile } from '@/hooks/useIsMobile' -import { - ChartContainer, - GRID_DEFAULTS, - areaGradient, - areaGradientUrl, - chartTooltipProps, - currencyTooltipFormatter, - shouldAnimate, - xAxisDefaults, - yAxisDefaults, -} from '@/components/ui' -import { rawColors } from '@/constants/colors' -import { CHART_TEXT } from '@/constants/chartColors' -import { formatCurrencyShort } from '@/lib/formatters' - -import { STATUS_CONFIG } from '../budgetUtils' -import type { BudgetRow } from '../types' - -interface BudgetChartsProps { - chartData: Array<{ name: string; Budget: number; Spent: number; status: BudgetRow['status'] }> - burndownData: Array<{ day: number; ideal: number; actual: number | undefined }> - usageData: Array<{ category: string; usage: number; status: BudgetRow['status'] }> -} - -export function BudgetCharts({ chartData, burndownData, usageData }: Readonly) { - const navigate = useNavigate() - const isMobile = useIsMobile() - - // On phones the y-axis category labels would eat a quarter of a 390px card, - // so narrow the label gutter; per-row bars also read better with a touch - // more height than the desktop 256px when there are several categories. - const barYWidth = isMobile ? 64 : 96 - const bvaHeight = isMobile ? Math.max(220, chartData.length * 44) : 256 - const usageHeight = isMobile ? Math.max(220, usageData.length * 44) : 256 - - return ( - <> - -
-

Budget vs Actual

-

- Bar color reflects status -- tap a row to see its transactions -

-
-
- - STATUS_CONFIG[(row as { status: BudgetRow['status'] }).status].color, - }, - ]} - barGap={2} - tooltipFormatter={(v) => formatCurrencyShort(v)} - xTickFormatter={(v) => formatCurrencyShort(Number(v))} - onBarClick={(name) => navigate(`/transactions?category=${encodeURIComponent(name)}`)} - ariaLabel="Horizontal grouped bar chart comparing each category's budget against actual spending, spent bars colored by status" - /> -
-
- - {(burndownData.length > 0 || usageData.length > 0) && ( -
- {burndownData.length > 0 && ( - -
-
-

Budget Burn-down

-

- Remaining budget pace for{' '} - {new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} -

-
-
- - {' '} - Ideal - - - {' '} - Actual - -
-
-
- - - {areaGradient('burnActual', rawColors.app.green, 0.35, 0.02)} - - `${v}`} - /> - - `Day ${label}`} - formatter={(value, name) => [ - currencyTooltipFormatter(value), - name === 'ideal' ? 'Ideal Pace' : 'Actual Remaining', - ]} - /> - - - - -
-
- )} - - {usageData.length >= 3 && ( - -
-

Budget Utilization

-

- Percent of budget used per category (highest first) -- the dashed line marks 100% -

-
-
- - STATUS_CONFIG[(row as { status: BudgetRow['status'] }).status].color, - }, - ]} - showLegend={false} - tooltipFormatter={(v) => `${v}%`} - xTickFormatter={(v) => `${v}%`} - referenceLines={[{ x: 100, label: '100%', color: CHART_TEXT.subtle }]} - ariaLabel="Horizontal bar chart of budget utilization percentage per category, sorted highest first, with a reference line at 100 percent" - /> -
-
- )} -
- )} - - ) -} diff --git a/frontend/src/pages/budget/components/BudgetDefaultsPanel.tsx b/frontend/src/pages/budget/components/BudgetDefaultsPanel.tsx deleted file mode 100644 index b8348718..00000000 --- a/frontend/src/pages/budget/components/BudgetDefaultsPanel.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Contextual Budget Defaults panel for the Budget page. - * - * Renders the Settings `BudgetDefaultsSubsection` as-is inside a collapsible - * panel so the same controls are reachable in-context. Save behavior is - * identical to Settings: it writes the same fields via the dedicated - * budget-defaults preferences endpoint. The Settings page keeps its own copy. - */ - -import { useMemo, useState } from 'react' -import { Save, Sliders } from 'lucide-react' -import { toast } from 'sonner' - -import { CollapsibleSection } from '@/components/ui' -import { usePreferences, useUpdateBudgetDefaults } from '@/hooks/api/usePreferences' -import { useDemoGuard } from '@/hooks/useDemoGuard' -import type { LocalPrefs, LocalPrefKey } from '@/pages/settings/types' -import BudgetDefaultsSubsection from '@/pages/settings/sections/BudgetDefaultsSubsection' - -type BudgetDefaults = Pick< - LocalPrefs, - 'default_budget_alert_threshold' | 'auto_create_budgets' | 'budget_rollover_enabled' -> - -export default function BudgetDefaultsPanel() { - const { data: preferences } = usePreferences() - const updateBudgetDefaults = useUpdateBudgetDefaults() - const { guardDemoAction } = useDemoGuard() - - const [edits, setEdits] = useState>({}) - const [hasChanges, setHasChanges] = useState(false) - - const localPrefs = useMemo(() => { - if (!preferences) return null - return { ...preferences, ...edits } as unknown as LocalPrefs - }, [preferences, edits]) - - const updateLocalPref = (key: K, value: LocalPrefs[K]) => { - setEdits((prev) => ({ ...prev, [key]: value })) - setHasChanges(true) - } - - const handleSave = async () => { - if (!localPrefs || guardDemoAction('Saving budget defaults')) return - try { - await updateBudgetDefaults.mutateAsync({ - default_budget_alert_threshold: localPrefs.default_budget_alert_threshold, - auto_create_budgets: localPrefs.auto_create_budgets, - budget_rollover_enabled: localPrefs.budget_rollover_enabled, - }) - setHasChanges(false) - setEdits({}) - toast.success('Budget defaults saved') - } catch { - toast.error('Failed to save budget defaults') - } - } - - if (!localPrefs) return null - - return ( - - -
- {hasChanges && ( - - Unsaved - - )} - -
-
- ) -} diff --git a/frontend/src/pages/budget/components/BudgetRowItem.tsx b/frontend/src/pages/budget/components/BudgetRowItem.tsx deleted file mode 100644 index 625701a4..00000000 --- a/frontend/src/pages/budget/components/BudgetRowItem.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useState } from 'react' - -import { motion } from 'framer-motion' -import { AlertTriangle, CheckCircle, Edit2, Trash2 } from 'lucide-react' - -import { ProgressBar } from '@/components/shared' -import Sparkline from '@/components/shared/Sparkline' -import { ConfirmDialog } from '@/components/ui' -import { rawColors } from '@/constants/colors' -import { formatCurrency, formatPercent } from '@/lib/formatters' -import type { CategoryMomentum } from '@/lib/momentumCalculator' - -import { STATUS_CONFIG } from '../budgetUtils' -import type { BudgetPeriod, BudgetRow } from '../types' - -interface BudgetRowItemProps { - row: BudgetRow - isEditing: boolean - alertThreshold: number - isFixed: boolean - momentum: CategoryMomentum | undefined - /** Current day-of-month (1-based) and total days, for the month-end pace projection. */ - todayDayOfMonth: number - daysInMonth: number - onEdit: () => void - onCancelEdit: () => void - onSave: (limit: number, period: BudgetPeriod) => void - onDelete: () => void -} - -const MOMENTUM_COLOR = { - accelerating: rawColors.app.red, - decelerating: rawColors.app.green, - stable: rawColors.app.yellow, -} - -const MOMENTUM_CLASS = { - accelerating: 'text-app-red', - decelerating: 'text-app-green', - stable: 'text-app-yellow', -} - -export function BudgetRowItem(props: Readonly) { - const { - row, - isEditing, - alertThreshold, - isFixed, - momentum, - todayDayOfMonth, - daysInMonth, - onEdit, - onCancelEdit, - onSave, - onDelete, - } = props - const [confirmOpen, setConfirmOpen] = useState(false) - const cfg = STATUS_CONFIG[row.status] - const key = row.subcategory ? `${row.category}::${row.subcategory}` : row.category - - // Month-end pace projection (monthly budgets only). Extrapolate the current - // month-to-date spend across the full month: projected = spent / dayN * days. - // If that overshoots the limit, estimate the day the budget runs out. - const projectedTotal = - row.period === 'monthly' && todayDayOfMonth > 0 && row.spent > 0 - ? (row.spent / todayDayOfMonth) * daysInMonth - : null - const projectedOver = projectedTotal !== null && projectedTotal > row.limit - const overByDay = (() => { - if (!projectedOver || row.spent <= 0) return null - const day = Math.ceil((row.limit / row.spent) * todayDayOfMonth) - // Once already over today, the crossover day is in the past -- don't claim a - // future "over by Day N"; the red projected-total already says it's over. - return day >= todayDayOfMonth && day <= daysInMonth ? day : null - })() - - return ( - -
-
- {row.status === 'exceeded' ? ( - - ) : ( - - )} -
- {row.category} - {row.subcategory && ( - / {row.subcategory} - )} -
- - {row.period} - - {isFixed && ( - - Fixed - - )} - {momentum && momentum.sparklineData.length >= 3 && ( -
- - - {momentum.slope > 0 ? '+' : ''} - {momentum.slope}% - -
- )} -
-
- {isEditing ? ( - { - const v = Number.parseFloat(e.target.value) - if (Number.isFinite(v) && v >= 0) onSave(v, row.period) - else onCancelEdit() - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - const v = Number.parseFloat((e.target as HTMLInputElement).value) - if (Number.isFinite(v) && v >= 0) onSave(v, row.period) - else onCancelEdit() - } - if (e.key === 'Escape') onCancelEdit() - }} - className="w-28 px-2 py-1 rounded-lg bg-surface-3 backdrop-blur-xl border border-border text-sm text-foreground" - autoFocus - /> - ) : ( - <> - - {formatPercent(row.percentage)} - - - - - )} -
-
- -
- -
- -
- {row.remaining >= 0 ? ( -

- {formatCurrency(row.remaining)} left to spend -

- ) : ( -

- {formatCurrency(Math.abs(row.remaining))} over budget -

- )} -
-
- {formatCurrency(row.spent)} spent - of {formatCurrency(row.limit)} -
- - {projectedTotal !== null && ( -

- On pace for {formatCurrency(Math.round(projectedTotal))} by month-end - {overByDay !== null && ( - -- ~over by Day {overByDay} - )} -

- )} - - -
- ) -} diff --git a/frontend/src/pages/budget/components/CategoryTable.tsx b/frontend/src/pages/budget/components/CategoryTable.tsx new file mode 100644 index 00000000..c7112705 --- /dev/null +++ b/frontend/src/pages/budget/components/CategoryTable.tsx @@ -0,0 +1,277 @@ +import { useMemo, useState } from 'react' + +import { ChevronRight, PiggyBank, ShoppingBag, Target } from 'lucide-react' + +import { Money } from '@/components/ui' +import { formatCurrency } from '@/lib/formatters' +import type { SpendingBucket, SpendingRuleCategoryRow } from '@/services/api/analyticsV2' + +interface Props { + readonly rows: readonly SpendingRuleCategoryRow[] + readonly months: number +} + +const BUCKET_META: Record< + SpendingBucket, + { + readonly label: string + readonly icon: typeof Target + readonly tintClass: string + } +> = { + needs: { label: 'Needs', icon: Target, tintClass: 'text-app-blue' }, + wants: { label: 'Wants', icon: ShoppingBag, tintClass: 'text-app-orange' }, + savings: { label: 'Savings', icon: PiggyBank, tintClass: 'text-app-green' }, +} + +const BUCKET_ORDER: readonly SpendingBucket[] = ['needs', 'wants', 'savings'] + +/** Cap visible rows per bucket -- rows 11+ collapse into "Other (N more)". */ +const TOP_N = 10 + +type CategoryRowWithMeta = SpendingRuleCategoryRow & { + readonly _isOther?: boolean + readonly _rollup?: readonly SpendingRuleCategoryRow[] +} + +function buildBucketRows( + bucketRows: readonly SpendingRuleCategoryRow[], + months: number, +): readonly CategoryRowWithMeta[] { + const sorted = [...bucketRows].sort((a, b) => b.total_amount - a.total_amount) + if (sorted.length <= TOP_N) return sorted + + const top = sorted.slice(0, TOP_N) + const tail = sorted.slice(TOP_N) + const tailTotal = tail.reduce((s, r) => s + r.total_amount, 0) + const tailTxns = tail.reduce((s, r) => s + r.txn_count, 0) + const tailMonths = Math.max(0, ...tail.map((r) => r.months_seen)) + + return [ + ...top, + { + category: `Other (${tail.length} more)`, + subcategory: null, + bucket: sorted[0].bucket, + total_amount: tailTotal, + avg_monthly: tailTotal / Math.max(months, 1), + txn_count: tailTxns, + months_seen: tailMonths, + top_subs: [], + _isOther: true, + _rollup: tail, + }, + ] +} + +/** + * Three-column Needs / Wants / Savings breakdown. + * + * ## Layout: hand-rolled flex rows, NOT DataTable + * + * Three DataTables inside a `lg:grid-cols-3` grid each get ~33% of viewport. + * DataTable has no `whitespace-nowrap` on `
` and no `table-layout: fixed`, + * so at 33% width the amount digits truncate ("₹12,91" instead of "₹12,913.24"). + * The app's `CategoryBreakdown.tsx` + `TopMerchants.tsx` solve this exact + * problem with a hand-rolled `flex items-center gap-2` row: + * - `flex-1 min-w-0` on the name+subcategory block (truncates cleanly) + * - `shrink-0 w-24 text-right tabular-nums whitespace-nowrap` on the amount + * (never truncates, always right-aligned) + * That's the canonical pattern here. Rows are already sorted by amount desc + * from the backend so no sort-header UI is needed. + */ +export function CategoryTable({ rows, months }: Props) { + const bucketData = useMemo(() => { + const buckets: Record = { + needs: [], + wants: [], + savings: [], + } + for (const row of rows) buckets[row.bucket].push(row) + return buckets + }, [rows]) + + if (rows.length === 0) { + return ( +
+ No transactions in this period. +
+ ) + } + + return ( +
+ {BUCKET_ORDER.map((bucket) => ( + + ))} +
+ ) +} + +interface ColProps { + readonly bucket: SpendingBucket + readonly rows: readonly SpendingRuleCategoryRow[] + readonly months: number +} + +function BucketColumn({ bucket, rows, months }: ColProps) { + const meta = BUCKET_META[bucket] + const Icon = meta.icon + const [expandedOther, setExpandedOther] = useState(false) + + const visible = useMemo(() => buildBucketRows(rows, months), [rows, months]) + const rollupRow = useMemo( + () => visible.find((r) => (r as CategoryRowWithMeta)._isOther) as CategoryRowWithMeta | undefined, + [visible], + ) + const bucketTotal = rows.reduce((sum, r) => sum + r.total_amount, 0) + const bucketAvg = bucketTotal / Math.max(months, 1) + + return ( +
+ {/* Column header: bucket label + count on left, monthly avg on right */} +
+
+
+
+ +
/ mo avg
+
+
+ + {rows.length === 0 ? ( +
+ No categories in {meta.label}. +
+ ) : ( +
+ {visible.map((row) => { + const rowMeta = row as CategoryRowWithMeta + const key = rowMeta._isOther ? '__other__' : `${row.category}::${row.subcategory ?? ''}` + return ( + setExpandedOther((v) => !v) : undefined} + /> + ) + })} + + {/* "Other" expanded contents: full list of rolled-up categories */} + {expandedOther && rollupRow?._rollup && ( +
+ {rollupRow._rollup.map((r) => ( +
+ + {r.category} + + + {formatCurrency(r.avg_monthly)} + +
+ ))} +
+ )} +
+ )} +
+ ) +} + +interface RowProps { + readonly row: CategoryRowWithMeta + readonly isOther: boolean + readonly expanded: boolean + readonly onToggle?: () => void +} + +/** + * One category row: name + (up to 3) subcategory hints on the left, + * monthly-average amount right-aligned. Amount NEVER truncates. + * + * Layout follows `CategoryBreakdown.tsx:167-201` -- flex row with flex-1/min-w-0 + * name block and shrink-0/text-right amount. The subcategory line is a second + *
inside the flex-1 block, so truncation happens on both name and subs + * without ever compressing the amount cell. + */ +function CategoryRow({ row, isOther, expanded, onToggle }: RowProps) { + const subLine = + !isOther && row.top_subs && row.top_subs.length > 0 + ? row.top_subs + .filter((s) => s.name !== '(no subcategory)') + .slice(0, 3) + .map((s) => s.name) + .join(' · ') + : null + + const inner = ( +
+ {isOther && ( +
+ ) + + if (isOther && onToggle) { + return ( + + ) + } + + return inner +} diff --git a/frontend/src/pages/budget/components/PeriodPicker.tsx b/frontend/src/pages/budget/components/PeriodPicker.tsx new file mode 100644 index 00000000..031849aa --- /dev/null +++ b/frontend/src/pages/budget/components/PeriodPicker.tsx @@ -0,0 +1,218 @@ +import { useCallback, useEffect, useState } from 'react' + +import { AnimatePresence, motion } from 'framer-motion' +import { Calendar } from 'lucide-react' + +/** + * Value model: + * - Preset periods are string literals (fast path, no date math from user). + * - 'custom' means the caller reads `customStart` / `customEnd` from the + * controlled state instead of running toPeriodRange(). + */ +export type PresetPeriod = + | 'last_3_months' + | 'last_6_months' + | 'last_12_months' + | 'last_2_years' + | 'last_5_years' + | 'all_time' + | 'this_fy' + | 'custom' + +const OPTIONS: ReadonlyArray = [ + ['last_3_months', '3 mo'], + ['last_6_months', '6 mo'], + ['last_12_months', '1 yr'], + ['last_2_years', '2 yr'], + ['last_5_years', '5 yr'], + ['all_time', 'All'], + ['this_fy', 'FY'], +] + +interface Props { + readonly value: PresetPeriod + readonly onChange: (v: PresetPeriod) => void + readonly customStart: string + readonly customEnd: string + readonly onCustomChange: (start: string, end: string) => void + /** ISO of the earliest txn (from useDataDateRange). Bounds the custom min. */ + readonly minDate?: string + /** ISO of the latest txn. Bounds the custom max. */ + readonly maxDate?: string +} + +/** + * Preset pill bar + "Custom" trigger that opens a full-screen dialog. + * + * The dialog pattern (fixed inset-0 z-50 with backdrop-blur + bg-surface-dropdown + * panel) mirrors ConfirmDialog / AuthModal / ProfileModal / CommandPalette -- + * the app's canonical overlay shape. Previous version rendered the popover + * inline inside the sticky header, which pushed the flex layout wide and got + * clipped by page content. + */ +export function PeriodPicker({ + value, + onChange, + customStart, + customEnd, + onCustomChange, + minDate, + maxDate, +}: Props) { + const [showCustom, setShowCustom] = useState(false) + const handleClose = useCallback(() => setShowCustom(false), []) + + useEffect(() => { + if (!showCustom) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [showCustom, handleClose]) + + const minAttr = minDate ? minDate.slice(0, 10) : undefined + const maxAttr = maxDate ? maxDate.slice(0, 10) : undefined + const canApply = customStart && customEnd && customStart <= customEnd + + return ( + <> +
+ {OPTIONS.map(([v, label]) => ( + onChange(v)} + className={`relative px-3 py-2.5 sm:py-1.5 rounded-lg text-sm font-medium transition-colors ${ + value === v + ? 'text-foreground' + : 'text-muted-foreground hover:text-foreground hover:bg-[var(--overlay-5)]' + }`} + whileTap={{ scale: 0.97 }} + > + {value === v && ( + + )} + {label} + + ))} + + setShowCustom(true)} + className={`relative px-3 py-2.5 sm:py-1.5 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${ + value === 'custom' + ? 'text-foreground' + : 'text-muted-foreground hover:text-foreground hover:bg-[var(--overlay-5)]' + }`} + whileTap={{ scale: 0.97 }} + > + {value === 'custom' && ( + + )} + +
+ + + {showCustom && ( + + e.stopPropagation()} + > +

+ Custom date range +

+

+ Pick any start and end date. Bounds are set from your earliest + and latest transactions. +

+ +
+ + +
+ +
+ + +
+
+
+ )} +
+ + ) +} diff --git a/frontend/src/pages/budget/types.ts b/frontend/src/pages/budget/types.ts deleted file mode 100644 index edee79f9..00000000 --- a/frontend/src/pages/budget/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type BudgetPeriod = 'monthly' | 'yearly' -export type ViewMode = 'category' | 'subcategory' - -export interface BudgetRow { - category: string - subcategory?: string - limit: number - period: BudgetPeriod - spent: number - percentage: number - remaining: number - status: 'safe' | 'warning' | 'danger' | 'exceeded' -} diff --git a/frontend/src/pages/budget/useBudget.ts b/frontend/src/pages/budget/useBudget.ts deleted file mode 100644 index 48faf71f..00000000 --- a/frontend/src/pages/budget/useBudget.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' - -import { toast } from 'sonner' - -import { useCategoryBreakdown } from '@/hooks/api/useAnalytics' -import { usePreferences } from '@/hooks/api/usePreferences' -import { useTransactions } from '@/hooks/api/useTransactions' -import { getCurrentFY, getFYDateRange } from '@/lib/dateUtils' -import { parseStringArray } from '@/lib/formatters' -import { computeCategoryMomentum } from '@/lib/momentumCalculator' -import { useBudgetStore } from '@/store/budgetStore' - -import { buildStatus } from './budgetUtils' -import type { BudgetPeriod, BudgetRow, ViewMode } from './types' - -export function useBudget() { - const { data: transactions = [], isError: transactionsError } = useTransactions() - const { data: categoryData, isError: categoryError } = useCategoryBreakdown({ - transaction_type: 'expense', - }) - const { data: preferences, isError: preferencesError } = usePreferences() - const isError = transactionsError || categoryError || preferencesError - const fiscalYearStartMonth = preferences?.fiscal_year_start_month || 4 - const alertThreshold = preferences?.default_budget_alert_threshold ?? 80 - - const categoryMomentum = useMemo(() => computeCategoryMomentum(transactions), [transactions]) - - const fixedExpenseCategories = useMemo>( - () => - new Set(parseStringArray(preferences?.fixed_expense_categories).map((c) => c.toLowerCase())), - [preferences?.fixed_expense_categories], - ) - - const getStatus = useCallback( - (pct: number): BudgetRow['status'] => buildStatus(pct, alertThreshold), - [alertThreshold], - ) - - const { budgets, setBudget, removeBudget } = useBudgetStore() - - const [viewMode, setViewMode] = useState('category') - const [budgetPeriod, setBudgetPeriod] = useState('monthly') - const [isAdding, setIsAdding] = useState(false) - const [editKey, setEditKey] = useState(null) - const [formCategory, setFormCategory] = useState('') - const [formSubcategory, setFormSubcategory] = useState('') - const [formLimit, setFormLimit] = useState('') - - const currentMonthKey = useMemo(() => { - const now = new Date() - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` - }, []) - - // Current day-of-month and total days, shared by the burndown chart and the - // per-row month-end pace projection so they stay in sync. - const monthProgress = useMemo(() => { - const now = new Date() - const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() - return { todayDay: now.getDate(), daysInMonth } - }, []) - - const fyRange = useMemo(() => { - const fy = getCurrentFY(fiscalYearStartMonth) - return getFYDateRange(fy, fiscalYearStartMonth) - }, [fiscalYearStartMonth]) - - const spendingData = useMemo(() => { - const byCategory: Record = {} - const bySubcategory: Record = {} - const byCategoryYearly: Record = {} - const bySubcategoryYearly: Record = {} - - const addToMaps = ( - catMap: Record, - subMap: Record, - cat: string, - sub: string | null, - amt: number, - ) => { - catMap[cat] = (catMap[cat] || 0) + amt - if (sub) subMap[sub] = (subMap[sub] || 0) + amt - } - - for (const tx of transactions) { - if (tx.type !== 'Expense') continue - const amt = Math.abs(tx.amount) - const cat = tx.category || 'Uncategorized' - const sub = tx.subcategory ? `${cat}::${tx.subcategory}` : null - const dateKey = tx.date.substring(0, 10) - - if (tx.date.startsWith(currentMonthKey)) { - addToMaps(byCategory, bySubcategory, cat, sub, amt) - } - - if (dateKey >= fyRange.start && dateKey <= fyRange.end) { - addToMaps(byCategoryYearly, bySubcategoryYearly, cat, sub, amt) - } - } - - return { byCategory, bySubcategory, byCategoryYearly, bySubcategoryYearly } - }, [transactions, currentMonthKey, fyRange]) - - const allCategories = useMemo(() => { - const cats = new Set() - if (categoryData?.categories) { - for (const c of Object.keys(categoryData.categories)) cats.add(c) - } - for (const tx of transactions) { - if (tx.type === 'Expense' && tx.category) cats.add(tx.category) - } - return Array.from(cats).sort((a, b) => a.localeCompare(b)) - }, [categoryData, transactions]) - - const subcategoriesForCategory = useMemo(() => { - const map: Record> = {} - for (const tx of transactions) { - if (tx.type === 'Expense' && tx.category && tx.subcategory) { - if (!map[tx.category]) map[tx.category] = new Set() - map[tx.category].add(tx.subcategory) - } - } - return Object.fromEntries( - Object.entries(map).map(([k, v]) => [k, Array.from(v).sort((a, b) => a.localeCompare(b))]), - ) - }, [transactions]) - - const budgetRows = useMemo((): BudgetRow[] => { - return budgets.map((b) => { - const isSubcat = b.category.includes('::') - const monthlyMap = isSubcat ? spendingData.bySubcategory : spendingData.byCategory - const yearlyMap = isSubcat ? spendingData.bySubcategoryYearly : spendingData.byCategoryYearly - const spendMap = b.period === 'monthly' ? monthlyMap : yearlyMap - const spent = spendMap[b.category] || 0 - const percentage = b.limit > 0 ? (spent / b.limit) * 100 : 0 - const parts = b.category.split('::') - - return { - category: parts[0], - subcategory: parts[1], - limit: b.limit, - period: b.period, - spent, - percentage, - remaining: b.limit - spent, - status: getStatus(percentage), - } - }) - }, [budgets, spendingData, getStatus]) - - const filteredRows = useMemo(() => { - let rows = budgetRows - if (viewMode === 'category') rows = rows.filter((r) => !r.subcategory) - else rows = rows.filter((r) => !!r.subcategory) - return rows.sort((a, b) => b.percentage - a.percentage) - }, [budgetRows, viewMode]) - - const summary = useMemo(() => { - const totalBudget = filteredRows.reduce((s, r) => s + r.limit, 0) - const totalSpent = filteredRows.reduce((s, r) => s + r.spent, 0) - const exceeded = filteredRows.filter((r) => r.status === 'exceeded').length - const onTrack = filteredRows.filter((r) => r.status === 'safe').length - return { totalBudget, totalSpent, exceeded, onTrack, count: filteredRows.length } - }, [filteredRows]) - - const chartData = useMemo(() => { - return filteredRows.slice(0, 8).map((r) => ({ - name: r.subcategory || r.category, - Budget: r.limit, - Spent: r.spent, - status: r.status, - })) - }, [filteredRows]) - - const burndownData = useMemo(() => { - const { daysInMonth } = monthProgress - - const totalBudget = filteredRows - .filter((r) => r.period === 'monthly') - .reduce((sum, r) => sum + r.limit, 0) - - if (totalBudget === 0) return [] - - const dailyExpense: number[] = new Array(daysInMonth).fill(0) - for (const tx of transactions) { - if (tx.type !== 'Expense') continue - if (!tx.date.startsWith(currentMonthKey)) continue - const dayNum = Number.parseInt(tx.date.substring(8, 10), 10) - if (dayNum >= 1 && dayNum <= daysInMonth) { - dailyExpense[dayNum - 1] += Math.abs(tx.amount) - } - } - - const cumulative: number[] = [] - let runningTotal = 0 - for (let i = 0; i < daysInMonth; i++) { - runningTotal += dailyExpense[i] - cumulative.push(runningTotal) - } - - const todayDay = monthProgress.todayDay - - return Array.from({ length: daysInMonth }, (_, i) => ({ - day: i + 1, - ideal: Math.round(totalBudget - (totalBudget / daysInMonth) * (i + 1)), - actual: i < todayDay ? Math.round(totalBudget - cumulative[i]) : undefined, - })) - }, [filteredRows, transactions, currentMonthKey, monthProgress]) - - const usageData = useMemo(() => { - // Top 8 by utilization (rows are sorted desc). Rendered as a sorted - // horizontal bar -- length encodes utilization far more readably than radar - // spokes, and full category names fit on the y-axis. - return filteredRows.slice(0, 8).map((r) => ({ - category: r.subcategory || r.category, - usage: Math.round(r.percentage), - status: r.status, - })) - }, [filteredRows]) - - const availableCategories = useMemo(() => { - const existing = new Set(budgets.map((b) => b.category)) - if (viewMode === 'category') { - return allCategories.filter((c) => !existing.has(c)) - } - const subs: string[] = [] - for (const [cat, sublist] of Object.entries(subcategoriesForCategory)) { - for (const sub of sublist) { - const key = `${cat}::${sub}` - if (!existing.has(key)) subs.push(key) - } - } - return subs.sort((a, b) => a.localeCompare(b)) - }, [budgets, allCategories, subcategoriesForCategory, viewMode]) - - const handleAdd = useCallback(() => { - const key = - viewMode === 'subcategory' && formSubcategory - ? `${formCategory}::${formSubcategory}` - : formCategory - if (!key || !formLimit) return - const limit = Number.parseFloat(formLimit) - if (!Number.isFinite(limit) || limit <= 0) { - toast.error('Enter a budget limit greater than 0') - return - } - setBudget(key, limit, budgetPeriod) - setFormCategory('') - setFormSubcategory('') - setFormLimit('') - setIsAdding(false) - }, [formCategory, formSubcategory, formLimit, viewMode, budgetPeriod, setBudget]) - - const handleQuickAdd = useCallback( - (cat: string, spent: number) => { - const suggested = Math.ceil((spent * 1.2) / 1000) * 1000 - setBudget(cat, suggested, budgetPeriod) - }, - [setBudget, budgetPeriod], - ) - - return { - isError, - alertThreshold, - fixedExpenseCategories, - categoryMomentum, - budgets, - setBudget, - removeBudget, - viewMode, - setViewMode, - budgetPeriod, - setBudgetPeriod, - isAdding, - setIsAdding, - editKey, - setEditKey, - formCategory, - setFormCategory, - formSubcategory, - setFormSubcategory, - formLimit, - setFormLimit, - spendingData, - allCategories, - subcategoriesForCategory, - filteredRows, - summary, - chartData, - burndownData, - usageData, - monthProgress, - availableCategories, - handleAdd, - handleQuickAdd, - } -} diff --git a/frontend/src/pages/subscription-tracker/SubscriptionTrackerPage.tsx b/frontend/src/pages/subscription-tracker/SubscriptionTrackerPage.tsx index 1feda98a..f1692a33 100644 --- a/frontend/src/pages/subscription-tracker/SubscriptionTrackerPage.tsx +++ b/frontend/src/pages/subscription-tracker/SubscriptionTrackerPage.tsx @@ -6,7 +6,7 @@ import { RefreshCw, } from 'lucide-react' import { toast } from 'sonner' -import { PageHeader, ConfirmDialog } from '@/components/ui' +import { PageContainer, PageHeader, ConfirmDialog } from '@/components/ui' import { formatCurrency } from '@/lib/formatters' import EmptyState from '@/components/shared/EmptyState' import { CardGridSkeleton } from '@/components/shared/LoadingSkeleton' @@ -102,8 +102,7 @@ export default function SubscriptionTrackerPage() { } return ( -
-
+ )} -
{ if (deleteTarget) handleDelete(deleteTarget.id, deleteTarget.name) }} /> -
+ ) } diff --git a/frontend/src/pages/year-in-review/types.ts b/frontend/src/pages/year-in-review/types.ts index a0a94339..54de2c1c 100644 --- a/frontend/src/pages/year-in-review/types.ts +++ b/frontend/src/pages/year-in-review/types.ts @@ -18,27 +18,34 @@ export const MONTHS_SHORT = [ 'Dec', ] +/** + * Heatmap stops built from the APP palette (not tailwind-slate). Alpha suffix + * as 8-bit hex: 33 = 20%, 66 = 40%, A6 = 65%, E6 = 90%. Using the raw hex + * (rawColors.app.*) lets light-theme AA-adjusted values flow through + * automatically -- the previous literal `rgba(239,68,68,...)` etc. bypassed + * theme flip entirely. + */ export const heatmapColors: Record = { expense: [ rawColors.chart.grid, - 'rgba(239,68,68,0.20)', - 'rgba(239,68,68,0.40)', - 'rgba(239,68,68,0.65)', - 'rgba(239,68,68,0.90)', + `${rawColors.app.red}33`, + `${rawColors.app.red}66`, + `${rawColors.app.red}A6`, + `${rawColors.app.red}E6`, ], income: [ rawColors.chart.grid, - 'rgba(34,197,94,0.20)', - 'rgba(34,197,94,0.40)', - 'rgba(34,197,94,0.65)', - 'rgba(34,197,94,0.90)', + `${rawColors.app.green}33`, + `${rawColors.app.green}66`, + `${rawColors.app.green}A6`, + `${rawColors.app.green}E6`, ], net: [ rawColors.chart.grid, - 'rgba(59,130,246,0.20)', - 'rgba(59,130,246,0.40)', - 'rgba(59,130,246,0.65)', - 'rgba(59,130,246,0.90)', + `${rawColors.app.blue}33`, + `${rawColors.app.blue}66`, + `${rawColors.app.blue}A6`, + `${rawColors.app.blue}E6`, ], } diff --git a/frontend/src/pages/year-in-review/useYearInReview.ts b/frontend/src/pages/year-in-review/useYearInReview.ts index 5c8f5568..76bfc59c 100644 --- a/frontend/src/pages/year-in-review/useYearInReview.ts +++ b/frontend/src/pages/year-in-review/useYearInReview.ts @@ -3,7 +3,7 @@ import { useTransactions } from '@/hooks/api/useTransactions' import { useDailySummaries } from '@/hooks/api/useAnalyticsV2' import { usePreferences } from '@/hooks/api/usePreferences' import { usePreferencesStore } from '@/store/preferencesStore' -import { getCurrentFY, getCurrentMonth, getCurrentYear, toLocalDateKey, type AnalyticsViewMode } from '@/lib/dateUtils' +import { getCurrentFY, getCurrentMonth, getCurrentYear, MONTHS_PER_YEAR, toLocalDateKey, type AnalyticsViewMode } from '@/lib/dateUtils' import type { DayCell } from './components/DayOfWeekChart' import { accumulateStats, @@ -100,23 +100,37 @@ export function useYearInReview() { } }, [grid]) - const monthlyBarData = useMemo( - () => - MONTHS_SHORT.map((m, i) => { - const spending = stats.monthlyExpense[i] - const earning = stats.monthlyIncome[i] - return { - name: m, - Spending: spending, - Earning: earning, - // Net cash flow per month (positive = saved, negative = overspent). - // Used to drive the overlay line on the Monthly Breakdown chart so - // savings months stand out without the user doing the math. - Net: earning - spending, - } - }), - [stats], - ) + const monthlyBarData = useMemo(() => { + const now = new Date() + const nowYear = now.getFullYear() + const nowMonth = now.getMonth() + let cutoff = MONTHS_PER_YEAR + if (isFYMode) { + const fyStartYear = selectedYear + const fyEndYear = selectedYear + 1 + const isCurrentFY = + (nowYear === fyStartYear && nowMonth >= fiscalYearStartMonth - 1) || + (nowYear === fyEndYear && nowMonth < fiscalYearStartMonth - 1) + if (isCurrentFY) { + cutoff = ((nowMonth - (fiscalYearStartMonth - 1) + MONTHS_PER_YEAR) % MONTHS_PER_YEAR) + 1 + } + } else if (selectedYear === nowYear) { + cutoff = nowMonth + 1 + } + return MONTHS_SHORT.slice(0, cutoff).map((m, i) => { + const spending = stats.monthlyExpense[i] + const earning = stats.monthlyIncome[i] + return { + name: m, + Spending: spending, + Earning: earning, + // Net cash flow per month (positive = saved, negative = overspent). + // Used to drive the overlay line on the Monthly Breakdown chart so + // savings months stand out without the user doing the math. + Net: earning - spending, + } + }) + }, [stats, isFYMode, selectedYear, fiscalYearStartMonth]) return { transactions, diff --git a/frontend/src/services/api/aiConfig.ts b/frontend/src/services/api/aiConfig.ts index 0d7266ac..0ff4b158 100644 --- a/frontend/src/services/api/aiConfig.ts +++ b/frontend/src/services/api/aiConfig.ts @@ -34,7 +34,7 @@ export const aiConfigService = { }, async getKey(): Promise { - const response = await apiClient.get<{ api_key: string }>('/api/preferences/ai-config/key') + const response = await apiClient.get<{ api_key: string }>('/api/preferences/ai-config/key/reveal') return response.data.api_key }, diff --git a/frontend/src/services/api/analyticsV2.ts b/frontend/src/services/api/analyticsV2.ts index ad06e826..d7028880 100644 --- a/frontend/src/services/api/analyticsV2.ts +++ b/frontend/src/services/api/analyticsV2.ts @@ -424,4 +424,68 @@ export const analyticsV2Service = { const response = await apiClient.post('/api/analytics/v2/goals', data) return response.data }, + + // 50/30/20 budget-rule aggregation + async getSpendingRule(params?: { + start_date?: string + end_date?: string + }): Promise { + const response = await apiClient.get( + '/api/analytics/v2/spending-rule', + { params }, + ) + return response.data + }, +} + +// ─── 50/30/20 budget-rule types ──────────────────────────────────────────── + +export type SpendingBucket = 'needs' | 'wants' | 'savings' + +export interface SpendingRuleSubRow { + /** Subcategory label (e.g. "Office Cafeteria"), or "(no subcategory)" when null. */ + name: string + amount: number +} + +export interface SpendingRuleCategoryRow { + category: string + /** Backward-compat placeholder; always null under the category-grouped shape. + * Per-sub detail lives in `top_subs` (up to 3, sorted by amount desc). */ + subcategory: string | null + bucket: SpendingBucket + total_amount: number + avg_monthly: number + txn_count: number + months_seen: number + /** Top 3 subcategories by amount within this category. Empty for categories + * whose only sub is null (e.g. relabeled TRANSFER rows). */ + top_subs: readonly SpendingRuleSubRow[] +} + +export interface SpendingRuleBucket { + amount: number + pct_of_income: number + /** Signed: positive = on the good side of target + * (under-cap for Needs/Wants, over-floor for Savings). */ + score_delta: number +} + +export interface SpendingRuleResponse { + period: { + start: string + end: string + months: number + } + income_total: number + expense_total: number + /** Warren-style: income - expenses. Header card uses this. */ + savings_amount: number + targets: { + needs: number + wants: number + savings: number + } + buckets: Record + categories: SpendingRuleCategoryRow[] } diff --git a/frontend/src/services/api/client.ts b/frontend/src/services/api/client.ts index 1ad8d1f3..7e0e0898 100644 --- a/frontend/src/services/api/client.ts +++ b/frontend/src/services/api/client.ts @@ -21,6 +21,25 @@ function resolveDemoData(url: string, params: Record, txs: Tran if (url.includes('/calculations/account-balances')) return generateDemoAccountBalances(txs) if (url.includes('/calculations/category-breakdown')) return generateDemoCategoryBreakdown(txs, params) if (url.includes('/analytics/kpis')) return generateDemoKPIs(txs) + // Spending-rule returns a distinct shape from the other v2 endpoints; the + // /budgets page reads data.period.start etc, so the generic {data: []} + // stub below would blow up when the page renders. Return a zero-state + // response instead -- demo users see the empty-history layout, not a crash. + if (url.includes('/analytics/v2/spending-rule')) { + return { + period: { start: new Date().toISOString(), end: new Date().toISOString(), months: 1 }, + income_total: 0, + expense_total: 0, + savings_amount: 0, + targets: { needs: 50, wants: 30, savings: 20 }, + buckets: { + needs: { amount: 0, pct_of_income: 0, score_delta: 0 }, + wants: { amount: 0, pct_of_income: 0, score_delta: 0 }, + savings: { amount: 0, pct_of_income: 0, score_delta: 0 }, + }, + categories: [], + } + } if (url.includes('/analytics/v2/')) return { data: [], count: 0 } if (url.includes('/analytics/overview')) return generateDemoOverview(txs) if (url.includes('/analytics/behavior')) return generateDemoBehavior(txs) diff --git a/frontend/src/services/api/preferences.ts b/frontend/src/services/api/preferences.ts index 6842c3f4..d0756b29 100644 --- a/frontend/src/services/api/preferences.ts +++ b/frontend/src/services/api/preferences.ts @@ -241,7 +241,7 @@ export const preferencesService = { async getStockPrice(symbol: string): Promise<{ symbol: string; price: number; currency: string }> { const response = await apiClient.get<{ symbol: string; price: number; currency: string }>( - `/stock-price/${encodeURIComponent(symbol)}`, + `/api/stock-price/${encodeURIComponent(symbol)}`, ) return response.data }, @@ -253,7 +253,7 @@ export const preferencesService = { stale?: boolean fallback?: boolean }> { - const response = await apiClient.get('/exchange-rates', { params: { base } }) + const response = await apiClient.get('/api/exchange-rates', { params: { base } }) return response.data }, }