diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index b5a6338..6044c27 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -19,5 +19,11 @@ jobs:
- name: Install dependencies (runtime + dev)
run: uv sync --frozen --group dev
- - name: Run tests
- run: uv run pytest
+ - name: Lint (ruff)
+ run: uv run ruff check app tests
+
+ - name: Type-check (mypy)
+ run: uv run mypy
+
+ - name: Run tests (with coverage)
+ run: uv run pytest --cov --cov-report=term-missing
diff --git a/.gitignore b/.gitignore
index d40d1e9..cd19118 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,7 @@ subscriptions.db
subscriptions.db.bak.*
subscriptions.db-shm
subscriptions.db-wal
+
+# Coverage artifacts
+.coverage
+htmlcov/
diff --git a/README.md b/README.md
index 8fa3ae2..e278f83 100644
--- a/README.md
+++ b/README.md
@@ -43,10 +43,12 @@ uv run python main.py # http://localhost:5001 (live reload)
First visit walks you through creating the initial **super-admin** account.
-Run the tests:
+Lint, type-check, and test (the same checks CI runs):
```bash
-uv run pytest
+uv run ruff check app tests # lint + import sorting
+uv run mypy # type-check the logic/data layer
+uv run pytest --cov # tests with coverage
```
---
@@ -60,6 +62,7 @@ All configuration is via environment variables:
| `SUBTRACKER_SECRET` | **Prod** | random per-process | Session-signing key. **Set a stable value in production** β without it, logins do not survive a restart and a warning is logged. Generate with `openssl rand -hex 32`. |
| `SUBTRACKER_DB` | no | `./subscriptions.db` | SQLite file path. Point at a persistent volume in a container (e.g. `/data/subscriptions.db`). |
| `SUBTRACKER_PORT` | no | `5001` | Listen port. |
+| `SUBTRACKER_SECURE_COOKIES` | no | `0` | Set to `1` when serving over HTTPS: marks the session cookie `Secure` and sets `SameSite=strict`. |
The database runs in WAL mode and is created automatically on first boot.
@@ -141,12 +144,19 @@ test suite). Pushing to `master` never touches the stable image.
- Sessions are signed cookies keyed by `SUBTRACKER_SECRET`.
- Authorization is enforced per-route via the RBAC layer (`app/rbac.py`,
`app/authz.py`); roles compose by union across a global and a per-team axis.
+- **CSRF protection** (`app/csrf.py`): a per-session token is required on every
+ state-changing request. It rides in a `` tag; client JS attaches it to
+ POST forms (hidden field) and HTMX requests (`X-CSRFToken` header), and a
+ Beforeware rejects any unsafe request whose token doesn't match the session.
+- **Login/setup rate-limiting** (`app/ratelimit.py`): 10 attempts per 5 minutes
+ per client throttles online password guessing.
- CSV export quotes leading formula characters (`=`, `+`, `-`, `@`) to neutralise
spreadsheet formula injection, and imports are size- and row-capped.
-**Recommended hardening before exposing to untrusted networks** (not yet
-implemented): CSRF tokens on state-changing forms, login rate-limiting, and
-serving behind HTTPS with `Secure` session cookies.
+**Before exposing to untrusted networks**, serve behind HTTPS and set
+`SUBTRACKER_SECURE_COOKIES=1` β this marks the session cookie `Secure` (sent only
+over HTTPS) and tightens `SameSite` to `strict`. The in-memory rate limiter is
+per-process; a multi-worker deployment would want a shared store.
---
@@ -158,6 +168,9 @@ app/
config.py env-driven configuration
session.py auth gate (Beforeware) + request context
rbac.py / authz.py role catalog, permission resolution, per-route guard
+ permissions.py permission name catalog (the Perm constants)
+ csrf.py per-session CSRF token: guard, , client JS
+ ratelimit.py in-memory auth rate limiter
auth.py bcrypt hashing + authentication
cost_utils.py cadence math, prorated/period-aware cost, next payments
timeutil.py central date/time provider
@@ -165,7 +178,8 @@ app/
db/ schema (idempotent + migration), data access per entity
routes/ one APIRouter per feature area
components/ FastHTML view helpers (shadcn-styled) + charts
-tests/ pytest suite (cost math, RBAC, auth, migration, HTTP smoke)
+tests/ pytest suite (cost math, RBAC + HTTP enforcement, auth, CSRF,
+ rate-limit, lifecycle, import/export, migration, HTTP smoke)
Dockerfile production image (used by compose + the GHCR image build)
docker-compose.yml standalone deployment
repository.yaml Home Assistant add-on repository manifest
diff --git a/app/__main__.py b/app/__main__.py
index 6856903..a8fd77a 100644
--- a/app/__main__.py
+++ b/app/__main__.py
@@ -4,8 +4,8 @@
from fasthtml.common import serve
-from app.main import app # noqa: F401 (build the app)
from app.config import PORT
+from app.main import app # noqa: F401 (build the app)
if __name__ == "__main__":
serve(appname="app.main", app="app", port=PORT)
diff --git a/app/auth.py b/app/auth.py
index b12cc48..7aa93ed 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -24,7 +24,8 @@ def create_user(username: str, password: str, global_role: str = "user") -> int:
"global_role": global_role,
"created_at": timeutil.now_iso(),
})
- return result.last_pk
+ assert result.last_pk is not None
+ return int(result.last_pk)
def authenticate(username: str, password: str):
diff --git a/app/components/__init__.py b/app/components/__init__.py
index 6ab1647..b1626cb 100644
--- a/app/components/__init__.py
+++ b/app/components/__init__.py
@@ -2,23 +2,40 @@
app.components β stable import surface for all shared UI primitives.
"""
+from app.components.charts import bar_chart, hbar_breakdown, line_chart
from app.components.fmt import (
- MONTH_LABELS, fmt_eur, category_label, truncate, json_pretty,
-)
-from app.components.widgets import (
- alert, badge, status_badge, action_btn, action_menu, pagination_bar,
- dropdown_menu, menu_item_cls, select_menu,
+ MONTH_LABELS,
+ category_label,
+ fmt_eur,
+ json_pretty,
+ truncate,
)
+from app.components.forms import subscription_form
from app.components.layout import (
- nav_bar, page_title, section_card, collapsible_card, team_switcher, forbidden_page,
+ collapsible_card,
+ forbidden_page,
+ nav_bar,
+ page_title,
+ section_card,
+ team_switcher,
+)
+from app.components.widgets import (
+ action_btn,
+ action_menu,
+ alert,
+ badge,
+ dropdown_menu,
+ menu_item_cls,
+ pagination_bar,
+ select_menu,
+ status_badge,
)
-from app.components.charts import bar_chart, line_chart, hbar_breakdown
-from app.components.forms import subscription_form
__all__ = [
"MONTH_LABELS", "fmt_eur", "category_label", "truncate", "json_pretty",
"alert", "badge", "status_badge", "action_btn", "action_menu", "pagination_bar",
"dropdown_menu", "menu_item_cls", "select_menu",
"nav_bar", "page_title", "section_card", "collapsible_card",
+ "team_switcher", "forbidden_page",
"bar_chart", "line_chart", "hbar_breakdown", "subscription_form",
]
diff --git a/app/components/charts.py b/app/components/charts.py
index ebaddf0..8e257a1 100644
--- a/app/components/charts.py
+++ b/app/components/charts.py
@@ -4,7 +4,7 @@
"""
from fasthtml.common import *
-from fasthtml.svg import Svg, Rect, Line, Text, Polyline, Polygon, Circle
+from fasthtml.svg import Circle, Line, Polygon, Polyline, Rect, Svg, Text
# Shared SVG viewBox geometry for line_chart / bar_chart. The charts scale to their
# container via `w-full` + viewBox; these are the internal coordinate units only.
diff --git a/app/components/forms.py b/app/components/forms.py
index 4b17ccf..8e2d84c 100644
--- a/app/components/forms.py
+++ b/app/components/forms.py
@@ -10,8 +10,8 @@
from app import timeutil
from app.components.widgets import select_menu
-from app.cost_utils import FREQUENCIES, BASE_UNITS, frequency_label
-from app.styles import INPUT, TEXTAREA, LABEL, FIELD, btn
+from app.cost_utils import BASE_UNITS, FREQUENCIES, frequency_label
+from app.styles import FIELD, INPUT, LABEL, TEXTAREA, btn
def _field(label, control):
diff --git a/app/components/layout.py b/app/components/layout.py
index efe0b59..1c78746 100644
--- a/app/components/layout.py
+++ b/app/components/layout.py
@@ -6,8 +6,15 @@
from fasthtml.common import *
from app.components.widgets import alert, dropdown_menu, menu_item_cls
+from app.permissions import Perm
from app.styles import (
- NAV, NAV_LINK, NAV_LINK_ACTIVE, SECTION, PAGE_HEADER, btn, badge_cls,
+ NAV,
+ NAV_LINK,
+ NAV_LINK_ACTIVE,
+ PAGE_HEADER,
+ SECTION,
+ badge_cls,
+ btn,
)
@@ -72,12 +79,12 @@ def theme_toggle() -> Button:
def _nav_items(ctx) -> list:
"""[(label, href, key)] for the sections the current role may reach."""
items = [
- ("Dashboard", "/dashboard", "dashboard", ctx.can("subscriptions.view")),
- ("Manage", "/manage", "manage", ctx.can("subscriptions.view")),
- ("Audit Log", "/audit", "audit", ctx.can("audit.view")),
- ("Teams", "/teams", "teams", ctx.can("teams.manage")),
- ("Deleted", "/admin/deleted", "deleted", ctx.can("records.view_deleted")),
- ("Users", "/users", "users", ctx.can("users.view")),
+ ("Dashboard", "/dashboard", "dashboard", ctx.can(Perm.SUB_VIEW)),
+ ("Manage", "/manage", "manage", ctx.can(Perm.SUB_VIEW)),
+ ("Audit Log", "/audit", "audit", ctx.can(Perm.AUDIT_VIEW)),
+ ("Teams", "/teams", "teams", ctx.can(Perm.TEAMS_MANAGE)),
+ ("Deleted", "/admin/deleted", "deleted", ctx.can(Perm.RECORDS_VIEW_DELETED)),
+ ("Users", "/users", "users", ctx.can(Perm.USERS_VIEW)),
]
return [(lbl, href, key) for lbl, href, key, show in items if show]
@@ -132,14 +139,17 @@ def nav_bar(ctx, active: str = "") -> Nav:
cls="hidden md:flex items-center gap-3",
)
- return Nav(
- A("π³ SubTracker", href="/dashboard", cls="font-bold text-base mr-1"),
- desktop_links,
- Div(cls="flex-1"),
- desktop_right,
- Div(theme_toggle(), _mobile_menu(ctx, active, role_label),
- cls="flex md:hidden items-center gap-2"),
- cls=NAV,
+ return (
+ Meta(name="csrf-token", content=ctx.csrf_token),
+ Nav(
+ A("π³ SubTracker", href="/dashboard", cls="font-bold text-base mr-1"),
+ desktop_links,
+ Div(cls="flex-1"),
+ desktop_right,
+ Div(theme_toggle(), _mobile_menu(ctx, active, role_label),
+ cls="flex md:hidden items-center gap-2"),
+ cls=NAV,
+ ),
)
diff --git a/app/components/widgets.py b/app/components/widgets.py
index b201ce3..da47d1e 100644
--- a/app/components/widgets.py
+++ b/app/components/widgets.py
@@ -4,7 +4,7 @@
from fasthtml.common import *
-from app.styles import btn, badge_cls, ALERT
+from app.styles import ALERT, badge_cls, btn
# shadcn DropdownMenu content + item recipes. Show/hide is the native
# behaviour; the open animation + chevron rotation live in GLOBALS (app/styles.py),
diff --git a/app/config.py b/app/config.py
index 01f121a..8182aa0 100644
--- a/app/config.py
+++ b/app/config.py
@@ -27,3 +27,19 @@
"environment for production.", file=sys.stderr)
PORT = int(os.environ.get("SUBTRACKER_PORT", "5001"))
+
+
+def _flag(name: str, default: bool = False) -> bool:
+ """Read a boolean environment flag (1/true/yes/on)."""
+ val = os.environ.get(name)
+ if val is None:
+ return default
+ return val.strip().lower() in ("1", "true", "yes", "on")
+
+
+# Cookie hardening for production behind HTTPS. When SUBTRACKER_SECURE_COOKIES is
+# enabled the session cookie is marked Secure (browsers only send it over HTTPS)
+# and SameSite is tightened to 'strict'. Left off by default so plain-HTTP local
+# dev still works; turn it on for any internet-facing deployment.
+SECURE_COOKIES = _flag("SUBTRACKER_SECURE_COOKIES", False)
+SESSION_SAMESITE = "strict" if SECURE_COOKIES else "lax"
diff --git a/app/cost_utils.py b/app/cost_utils.py
index 99b997a..7ccf4f1 100644
--- a/app/cost_utils.py
+++ b/app/cost_utils.py
@@ -16,7 +16,6 @@
import calendar
from datetime import date, timedelta
-
# ββ Frequency model ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
FREQUENCIES = ["daily", "weekly", "monthly", "quarterly", "yearly", "custom"]
@@ -42,7 +41,7 @@
_UNIT_NOUN = {"daily": "day", "weekly": "week", "monthly": "month", "yearly": "year"}
-def resolve(frequency: str, interval: int = 1, base_unit: str = None) -> tuple:
+def resolve(frequency: str, interval: int = 1, base_unit: str | None = None) -> tuple:
"""Collapse (frequency, interval, base_unit) to an (effective_unit, n) pair."""
if frequency == "custom":
unit = base_unit if base_unit in BASE_UNITS else "monthly"
@@ -52,7 +51,7 @@ def resolve(frequency: str, interval: int = 1, base_unit: str = None) -> tuple:
return frequency, 1
-def normalise_cadence(frequency: str, interval=1, base_unit: str = None) -> tuple:
+def normalise_cadence(frequency: str, interval=1, base_unit: str | None = None) -> tuple:
"""Clean a submitted/imported (frequency, interval, base_unit) triple for storage.
A named preset always means "every 1 unit" with no base_unit. "custom" keeps a
@@ -74,19 +73,19 @@ def normalise_cadence(frequency: str, interval=1, base_unit: str = None) -> tupl
# ββ Cost normalisation βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def get_annual_cost(amount: float, frequency: str, interval: int = 1,
- base_unit: str = None) -> float:
+ base_unit: str | None = None) -> float:
unit, n = resolve(frequency, interval, base_unit)
days_between = DAYS_PER_UNIT[unit] * n
return round(amount * (365.25 / days_between), 2)
def get_period_cost(amount: float, frequency: str, interval: int,
- base_unit: str, period: str) -> float:
+ base_unit: str | None, period: str) -> float:
return round(get_annual_cost(amount, frequency, interval, base_unit)
/ PERIOD_DIVISORS[period], 2)
-def frequency_label(frequency: str, interval: int = 1, base_unit: str = None) -> str:
+def frequency_label(frequency: str, interval: int = 1, base_unit: str | None = None) -> str:
named = {"daily": "Daily", "weekly": "Weekly", "monthly": "Monthly",
"quarterly": "Quarterly", "yearly": "Yearly"}
if frequency != "custom":
@@ -120,7 +119,7 @@ def _advance(d: date, unit: str, n: int) -> date:
def next_payment_date(start_date_str: str, frequency: str, interval: int,
- base_unit: str = None, reference: date = None) -> date:
+ base_unit: str | None = None, reference: date | None = None) -> date:
"""Return the next payment date >= reference (defaults to today)."""
ref = reference or date.today()
unit, n = resolve(frequency, interval, base_unit)
@@ -133,13 +132,13 @@ def next_payment_date(start_date_str: str, frequency: str, interval: int,
def upcoming_payments_for_periods(sub: dict, periods: list, count: int = 6,
- reference: date = None) -> list:
+ reference: date | None = None) -> list:
"""
Return up to `count` upcoming [{date, amount}] payments at/after `reference`,
walking each period's cadence (anchored at its start) and clamped to its end.
"""
ref = reference or date.today()
- out = []
+ out: list = []
freq, interval, base_unit = sub["frequency"], sub.get("interval") or 1, sub.get("base_unit")
for p in sorted(periods, key=lambda x: x["start_date"]):
pe = date.fromisoformat(p["end_date"]) if p.get("end_date") else None
diff --git a/app/csrf.py b/app/csrf.py
new file mode 100644
index 0000000..192812d
--- /dev/null
+++ b/app/csrf.py
@@ -0,0 +1,96 @@
+"""
+csrf.py β Cross-site request forgery protection.
+
+Strategy (synchroniser-token, scoped to the signed session):
+ * a per-session random token is minted on first render and stored in the
+ (signed, HttpOnly) session cookie via `ensure_token`;
+ * every rendered page exposes it in a tag (see
+ `csrf_meta`, emitted by the nav bar and the login/setup pages);
+ * client JS (CSRF_JS, injected globally in app.main) copies that token into a
+ hidden `csrf_token` field on every POST form submit and into the
+ `X-CSRFToken` header on every HTMX request β so no individual form or button
+ needs to know about CSRF;
+ * `csrf_guard` (a Beforeware running ahead of the auth gate) rejects any unsafe
+ request whose submitted token does not match the session token.
+
+Because the token lives in the signed session cookie, an attacker on another
+origin can neither read it nor forge a request carrying the matching value.
+"""
+
+import secrets
+
+from fasthtml.common import Meta, Response, Script
+
+SESSION_KEY = "_csrf"
+FIELD_NAME = "csrf_token"
+HEADER_NAME = "x-csrftoken"
+SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
+
+# Static-asset paths the CSRF gate never needs to run on. Unlike the auth gate it
+# deliberately does NOT skip /login or /setup: those POSTs must be protected too.
+ASSET_SKIP = [r"/[^/]*\.(css|js|ico|png|jpe?g|svg|woff2?|map|txt)", r"/favicon\.ico"]
+
+
+def ensure_token(session: dict) -> str:
+ """Return the session's CSRF token, minting and storing one if absent."""
+ token = session.get(SESSION_KEY)
+ if not token:
+ token = secrets.token_urlsafe(32)
+ session[SESSION_KEY] = token
+ return token
+
+
+def csrf_meta(session: dict) -> Meta:
+ """ tag carrying the session CSRF token for the client JS to read."""
+ return Meta(name="csrf-token", content=ensure_token(session))
+
+
+async def csrf_guard(req, session):
+ """
+ Beforeware: reject unsafe requests lacking a valid CSRF token.
+
+ Returns a 403 Response on failure (which short-circuits the request) or None
+ to let the request proceed.
+ """
+ if req.method in SAFE_METHODS:
+ return None
+ expected = session.get(SESSION_KEY)
+ sent = req.headers.get(HEADER_NAME)
+ if not sent:
+ # Form was already parsed (and cached) by FastHTML before this runs.
+ form = await req.form()
+ sent = form.get(FIELD_NAME)
+ if not (expected and sent and secrets.compare_digest(str(sent), str(expected))):
+ return Response("CSRF validation failed. Reload the page and try again.",
+ status_code=403)
+ return None
+
+
+# Injected into every page (app.main hdrs). Auto-attaches the token to native POST
+# form submits and to HTMX requests, reading it from the tag, so handlers
+# and templates stay CSRF-unaware.
+CSRF_JS = Script("""
+(function () {
+ function token() {
+ var m = document.querySelector('meta[name="csrf-token"]');
+ return m ? m.getAttribute('content') : null;
+ }
+ // Native form POSTs: inject a hidden field just before submission.
+ document.addEventListener('submit', function (e) {
+ var form = e.target;
+ if (!form || !form.matches || !form.matches('form')) return;
+ if ((form.getAttribute('method') || 'get').toLowerCase() !== 'post') return;
+ if (form.querySelector('input[name="csrf_token"]')) return;
+ var t = token();
+ if (!t) return;
+ var i = document.createElement('input');
+ i.type = 'hidden'; i.name = 'csrf_token'; i.value = t;
+ form.appendChild(i);
+ }, true);
+ // HTMX requests: add the header for every non-GET issued by hx-* attributes.
+ document.addEventListener('htmx:configRequest', function (e) {
+ var t = token();
+ if (t) e.detail.headers['X-CSRFToken'] = t;
+ });
+})();
+""")
diff --git a/app/db/__init__.py b/app/db/__init__.py
index 3e723ee..0ac30c1 100644
--- a/app/db/__init__.py
+++ b/app/db/__init__.py
@@ -4,28 +4,60 @@
Route modules import from `app.db` so internal file moves stay invisible to handlers.
"""
-from app.db.connection import get_db, rows_as_dicts, one
-from app.db.schema import init_db, has_any_users
-from app.db.users import (
- get_user_by_username, get_user_by_id, get_all_users, username_taken,
- count_super_admins, set_global_role, soft_delete_user,
+from app.db.audit import (
+ audit,
+ get_audit_for_entity,
+ get_audit_log,
+ write_audit_log,
+)
+from app.db.connection import get_db, one, rows_as_dicts
+from app.db.roles import (
+ list_permissions,
+ list_roles,
+ permissions_for_role,
+ role_matrix,
+ set_role_permission,
)
+from app.db.schema import has_any_users, init_db
from app.db.subscriptions import (
- get_subscription, get_active_subscriptions, get_all_subscriptions,
- get_categories, get_periods, get_periods_map, is_active_on, current_price,
- upcoming_price_change, validate_periods, add_period, update_period, delete_period,
- restore_subscription, purge_subscription,
+ add_period,
+ current_price,
+ delete_period,
+ get_active_subscriptions,
+ get_all_subscriptions,
+ get_categories,
+ get_periods,
+ get_periods_map,
+ get_subscription,
+ is_active_on,
+ purge_subscription,
+ restore_subscription,
+ upcoming_price_change,
+ update_period,
+ validate_periods,
)
from app.db.teams import (
- get_team, list_all_teams, list_user_teams, get_membership, list_team_members,
- count_team_admins, member_count, create_team, add_member, set_member_role,
- remove_member, get_membership_by_id,
+ add_member,
+ count_team_admins,
+ create_team,
+ get_membership,
+ get_membership_by_id,
+ get_team,
+ list_all_teams,
+ list_team_members,
+ list_user_teams,
+ member_count,
+ remove_member,
+ set_member_role,
)
-from app.db.roles import (
- permissions_for_role, list_roles, list_permissions, role_matrix, set_role_permission,
-)
-from app.db.audit import (
- write_audit_log, audit, get_audit_for_entity, get_audit_log,
+from app.db.users import (
+ count_super_admins,
+ get_all_users,
+ get_user_by_id,
+ get_user_by_username,
+ set_global_role,
+ soft_delete_user,
+ username_taken,
)
__all__ = [
diff --git a/app/db/audit.py b/app/db/audit.py
index b6bfd4b..6685d58 100644
--- a/app/db/audit.py
+++ b/app/db/audit.py
@@ -10,13 +10,15 @@
from app import timeutil
from app.db.connection import get_db, rows_as_dicts
+from app.permissions import Perm
def write_audit_log(actor_user_id: int, actor_name: str, action: str,
entity_type: str, entity_id: int, entity_name: str,
description: str, old_values=None, new_values=None,
- actor_global_role: str = None,
- team_id: int = None, team_name: str = None, db=None) -> None:
+ actor_global_role: str | None = None,
+ team_id: int | None = None, team_name: str | None = None,
+ db=None) -> None:
# Reuse the caller's connection when given one (e.g. a bulk import loop) instead
# of opening a fresh SQLite connection per audited row.
(db or get_db())["audit_log"].insert({
@@ -38,7 +40,7 @@ def write_audit_log(actor_user_id: int, actor_name: str, action: str,
def audit(ctx, action: str, entity_type: str, entity_id: int, entity_name: str,
description: str, old_values=None, new_values=None,
- team_id: int = None, team_name: str = None, db=None) -> None:
+ team_id: int | None = None, team_name: str | None = None, db=None) -> None:
"""Convenience wrapper that snapshots actor + team from the request context.
Pass `db` to reuse an open connection (e.g. inside a bulk-import loop)."""
@@ -56,14 +58,14 @@ def get_audit_for_entity(db, entity_id: int, entity_type: str) -> list:
"ORDER BY timestamp DESC", [entity_id, entity_type])
-def get_audit_log(db, ctx, action_filter: str = None,
+def get_audit_log(db, ctx, action_filter: str | None = None,
page: int = 1, per_page: int = 25) -> tuple:
"""
With audit.view: the active team's entries (all teams in super-admin view-all).
Without it: only the caller's own actions.
"""
where, params = [], []
- if "audit.view" in ctx.perms:
+ if Perm.AUDIT_VIEW in ctx.perms:
if not (ctx.view_all and ctx.is_super):
where.append("team_id = ?")
params.append(ctx.active_team_id)
diff --git a/app/db/roles.py b/app/db/roles.py
index 0bc5d06..2f0d005 100644
--- a/app/db/roles.py
+++ b/app/db/roles.py
@@ -2,7 +2,6 @@
roles.py β Queries over the DB-driven roles / permissions / role_permissions tables.
"""
-from app.db.connection import rows_as_dicts
def permissions_for_role(db, role_name: str) -> set:
@@ -10,7 +9,7 @@ def permissions_for_role(db, role_name: str) -> set:
for r in db["role_permissions"].rows_where("role_name = ?", [role_name])}
-def list_roles(db, scope: str = None) -> list:
+def list_roles(db, scope: str | None = None) -> list:
where = "scope = ?" if scope else None
args = [scope] if scope else None
return list(db["roles"].rows_where(where, args, order_by="rank DESC"))
@@ -22,7 +21,7 @@ def list_permissions(db) -> list:
def role_matrix(db) -> dict:
"""{role_name: set(permission_name)} across every role."""
- m = {}
+ m: dict[str, set] = {}
for r in db["role_permissions"].rows:
m.setdefault(r["role_name"], set()).add(r["permission_name"])
return m
diff --git a/app/db/schema.py b/app/db/schema.py
index bf8e377..a999939 100644
--- a/app/db/schema.py
+++ b/app/db/schema.py
@@ -45,7 +45,7 @@ def _migrate_to_periods(db) -> None:
"subscription_id = ?", [sub["id"]]),
key=lambda h: (h["valid_from"], h["id"]))
- def price_at(d: str) -> float:
+ def price_at(d: str, base_amount=base_amount, history=history) -> float:
active = base_amount
for h in history:
if h["valid_from"] <= d:
@@ -57,7 +57,7 @@ def price_at(d: str) -> float:
if h["valid_from"] > start and (end is None or h["valid_from"] <= end)})
seg_starts = [start] + bps
- periods = []
+ periods: list[dict] = []
for i, ss in enumerate(seg_starts):
se = (date.fromisoformat(seg_starts[i + 1]) - timedelta(days=1)).isoformat() \
if i + 1 < len(seg_starts) else end
diff --git a/app/db/seed.py b/app/db/seed.py
index 90a15ba..2d27c6c 100644
--- a/app/db/seed.py
+++ b/app/db/seed.py
@@ -7,8 +7,13 @@
"""
from app.rbac import (
- PERMISSIONS, GLOBAL_ROLES, TEAM_ROLES, ROLE_PERMISSIONS,
- CANONICAL_ROLE_NAMES, GLOBAL_ROLE_NAMES, TEAM_ROLE_NAMES,
+ CANONICAL_ROLE_NAMES,
+ GLOBAL_ROLE_NAMES,
+ GLOBAL_ROLES,
+ PERMISSIONS,
+ ROLE_PERMISSIONS,
+ TEAM_ROLE_NAMES,
+ TEAM_ROLES,
)
diff --git a/app/db/subscriptions.py b/app/db/subscriptions.py
index bbb30dd..c3481f9 100644
--- a/app/db/subscriptions.py
+++ b/app/db/subscriptions.py
@@ -16,6 +16,7 @@
from app import timeutil
from app.db.connection import rows_as_dicts
+from app.permissions import Perm
def _team_clause(ctx) -> tuple:
@@ -36,17 +37,17 @@ def _team_clause(ctx) -> tuple:
def get_subscription(db, ctx, sub_id: int, include_deleted: bool = False):
tc, tp = _team_clause(ctx)
where = f"id = ? AND {tc}"
- if not (include_deleted and ctx.can("records.view_deleted")):
+ if not (include_deleted and ctx.can(Perm.RECORDS_VIEW_DELETED)):
where += " AND deleted_at IS NULL"
rows = list(db["subscriptions"].rows_where(where, [sub_id] + tp))
return rows[0] if rows else None
-def get_all_subscriptions(db, ctx, filter_active: str = None, search: str = None,
- category: str = None, only_deleted: bool = False) -> list:
+def get_all_subscriptions(db, ctx, filter_active: str | None = None, search: str | None = None,
+ category: str | None = None, only_deleted: bool = False) -> list:
tc, tp = _team_clause(ctx)
where, params = [tc], list(tp)
- if only_deleted and ctx.can("records.view_deleted"):
+ if only_deleted and ctx.can(Perm.RECORDS_VIEW_DELETED):
where.append("deleted_at IS NOT NULL")
else:
where.append("deleted_at IS NULL")
@@ -100,7 +101,7 @@ def get_periods(db, subscription_id: int) -> list:
def get_periods_map(db, subscription_ids: list) -> dict:
"""{subscription_id: [periods]} for many subscriptions in one query (avoids N+1)."""
- out = {sid: [] for sid in subscription_ids}
+ out: dict[int, list] = {sid: [] for sid in subscription_ids}
if not subscription_ids:
return out
placeholders = ",".join("?" * len(subscription_ids))
@@ -113,14 +114,14 @@ def get_periods_map(db, subscription_ids: list) -> dict:
return out
-def is_active_on(periods: list, reference_date: str = None) -> bool:
+def is_active_on(periods: list, reference_date: str | None = None) -> bool:
"""True if any period covers the reference date (defaults to today)."""
ref = reference_date or timeutil.today_iso()
return any(p["start_date"] <= ref and (p["end_date"] is None or p["end_date"] >= ref)
for p in periods)
-def upcoming_price_change(periods: list, reference_date: str = None):
+def upcoming_price_change(periods: list, reference_date: str | None = None):
"""
The next scheduled price change relative to the reference date (default today):
the earliest future period whose amount differs from the current price.
@@ -137,7 +138,7 @@ def upcoming_price_change(periods: list, reference_date: str = None):
return None
-def current_price(periods: list, reference_date: str = None):
+def current_price(periods: list, reference_date: str | None = None):
"""
Price in effect at the reference date: the covering period's amount, else the
most recent already-started period, else the earliest period. None if no periods.
diff --git a/app/db/teams.py b/app/db/teams.py
index d3ec4ee..821914e 100644
--- a/app/db/teams.py
+++ b/app/db/teams.py
@@ -5,8 +5,7 @@
import re
from app import timeutil
-from app.db.connection import rows_as_dicts, one
-
+from app.db.connection import one, rows_as_dicts
# ββ Reads ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/app/main.py b/app/main.py
index 5e1a5e2..961eda1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -12,11 +12,12 @@
from fasthtml.common import *
-from app.config import SECRET_KEY
-from app.styles import GLOBALS
+from app.config import SECRET_KEY, SECURE_COOKIES, SESSION_SAMESITE
+from app.csrf import ASSET_SKIP, CSRF_JS, csrf_guard
from app.db import init_db
from app.routes import ALL_ROUTERS
-from app.session import load_ctx, SKIP
+from app.session import SKIP, load_ctx
+from app.styles import GLOBALS
# Tailwind (Play CDN, v3) configured to consume shadcn's HSL tokens. Opacity
# modifiers (bg-primary/90, hover:bg-muted/50, β¦) work because the tokens are HSL.
@@ -143,9 +144,13 @@
Script(src="https://cdn.tailwindcss.com"),
Script(TAILWIND_CONFIG),
Script(DROPDOWN_JS),
+ CSRF_JS,
Style(GLOBALS, type="text/tailwindcss"),
),
- before=Beforeware(load_ctx, skip=SKIP),
+ # CSRF runs first (and on /login, /setup too); the auth gate runs second.
+ before=(Beforeware(csrf_guard, skip=ASSET_SKIP), Beforeware(load_ctx, skip=SKIP)),
+ same_site=SESSION_SAMESITE,
+ sess_https_only=SECURE_COOKIES,
)
# Register all route modules onto the single app instance.
diff --git a/app/permissions.py b/app/permissions.py
new file mode 100644
index 0000000..c08ef08
--- /dev/null
+++ b/app/permissions.py
@@ -0,0 +1,44 @@
+"""
+permissions.py β The permission catalog: names, labels, categories.
+
+Kept dependency-free (imports nothing from `app`) so any layer β routes, data
+helpers, the RBAC resolver β can reference `Perm` without risking a circular
+import. `Perm` is the single source of truth for permission NAMES: code uses the
+constants (a typo becomes an AttributeError at import time rather than a silent
+allow/deny), while the string VALUES stay stable for the DB role matrix.
+"""
+
+
+class Perm:
+ SUB_VIEW = "subscriptions.view"
+ SUB_CREATE = "subscriptions.create"
+ SUB_EDIT = "subscriptions.edit"
+ SUB_DELETE = "subscriptions.delete"
+ SUB_DELETE_PERMANENT = "subscriptions.delete.permanent"
+ RECORDS_RESTORE = "records.restore"
+ RECORDS_VIEW_DELETED = "records.view_deleted"
+ TEAMS_VIEW = "teams.view"
+ TEAMS_MANAGE = "teams.manage"
+ USERS_VIEW = "users.view"
+ USERS_MANAGE = "users.manage"
+ AUDIT_VIEW = "audit.view"
+ SETTINGS_MANAGE = "settings.manage"
+
+
+# (name, label, category) for each permission, in display order.
+PERMISSIONS = [
+ (Perm.SUB_VIEW, "View subscriptions", "Subscriptions"),
+ (Perm.SUB_CREATE, "Create subscriptions", "Subscriptions"),
+ (Perm.SUB_EDIT, "Edit subscriptions", "Subscriptions"),
+ (Perm.SUB_DELETE, "Delete (soft) subscriptions", "Subscriptions"),
+ (Perm.SUB_DELETE_PERMANENT, "Permanently delete records", "Subscriptions"),
+ (Perm.RECORDS_RESTORE, "Restore deleted records", "Records"),
+ (Perm.RECORDS_VIEW_DELETED, "View deleted records", "Records"),
+ (Perm.TEAMS_VIEW, "View / switch teams", "Teams"),
+ (Perm.TEAMS_MANAGE, "Manage teams & members", "Teams"),
+ (Perm.USERS_VIEW, "View users", "Users"),
+ (Perm.USERS_MANAGE, "Manage users & global roles", "Users"),
+ (Perm.AUDIT_VIEW, "View team audit log", "Audit"),
+ (Perm.SETTINGS_MANAGE, "Manage settings & role matrix", "Settings"),
+]
+ALL_PERMISSIONS = [p[0] for p in PERMISSIONS]
diff --git a/app/ratelimit.py b/app/ratelimit.py
new file mode 100644
index 0000000..6fa41ef
--- /dev/null
+++ b/app/ratelimit.py
@@ -0,0 +1,56 @@
+"""
+ratelimit.py β Minimal in-memory fixed-window rate limiting for auth endpoints.
+
+Guards /login and /setup against online brute-forcing. State is per-process (a
+plain dict of monotonic timestamps), which is the right scope for this app's
+single-worker deployment; it intentionally adds no external dependency. Restarting
+the process clears the counters, and a multi-worker deployment would need a shared
+store β both acceptable trade-offs for a self-hosted tool.
+"""
+
+import time
+from collections import defaultdict, deque
+
+
+class RateLimiter:
+ """Allow at most `max_attempts` recorded hits per key within `window` seconds."""
+
+ def __init__(self, max_attempts: int, window_seconds: float):
+ self.max_attempts = max_attempts
+ self.window = window_seconds
+ self._hits: dict[str, deque] = defaultdict(deque)
+
+ def _prune(self, key: str, now: float) -> deque:
+ dq = self._hits[key]
+ while dq and dq[0] <= now - self.window:
+ dq.popleft()
+ return dq
+
+ def is_limited(self, key: str) -> bool:
+ """True if `key` has already used up its allowance for the current window."""
+ return len(self._prune(key, time.monotonic())) >= self.max_attempts
+
+ def record(self, key: str) -> None:
+ """Record one attempt against `key`."""
+ now = time.monotonic()
+ self._prune(key, now).append(now)
+
+ def reset(self, key: str | None = None) -> None:
+ """Clear one key, or all keys when `key` is None (used by tests)."""
+ if key is None:
+ self._hits.clear()
+ else:
+ self._hits.pop(key, None)
+
+
+# Auth limiter: 10 attempts per 5 minutes per client. Generous enough that a human
+# fat-fingering a password is unaffected, tight enough to throttle a script.
+login_limiter = RateLimiter(max_attempts=10, window_seconds=300)
+
+
+def client_key(req) -> str:
+ """Best-effort client identity for rate-limiting (proxy-aware)."""
+ fwd = req.headers.get("x-forwarded-for")
+ if fwd:
+ return fwd.split(",")[0].strip()
+ return req.client.host if req.client else "unknown"
diff --git a/app/rbac.py b/app/rbac.py
index 0f76ed7..8140066 100644
--- a/app/rbac.py
+++ b/app/rbac.py
@@ -15,28 +15,22 @@
Permission STRINGS are fixed in code because enforcement references them.
"""
+from app.csrf import ensure_token
from app.db.roles import permissions_for_role
from app.db.teams import get_membership, get_team, list_all_teams, list_user_teams
-
-# ββ Permission catalog (name, label, category) βββββββββββββββββββββββββββββββ
-
-PERMISSIONS = [
- ("subscriptions.view", "View subscriptions", "Subscriptions"),
- ("subscriptions.create", "Create subscriptions", "Subscriptions"),
- ("subscriptions.edit", "Edit subscriptions", "Subscriptions"),
- ("subscriptions.delete", "Delete (soft) subscriptions", "Subscriptions"),
- ("subscriptions.delete.permanent","Permanently delete records", "Subscriptions"),
- ("records.restore", "Restore deleted records", "Records"),
- ("records.view_deleted", "View deleted records", "Records"),
- ("teams.view", "View / switch teams", "Teams"),
- ("teams.manage", "Manage teams & members", "Teams"),
- ("users.view", "View users", "Users"),
- ("users.manage", "Manage users & global roles", "Users"),
- ("audit.view", "View team audit log", "Audit"),
- ("settings.manage", "Manage settings & role matrix", "Settings"),
+# Re-exported so existing `from app.rbac import Perm, PERMISSIONS, ALL_PERMISSIONS`
+# imports keep working; the catalog itself lives in the dependency-free module.
+from app.permissions import ALL_PERMISSIONS, PERMISSIONS, Perm
+
+# `PERMISSIONS` is consumed by importers (e.g. app.db.seed), not by this module β
+# declaring it here marks the re-export as intentional.
+__all__ = [
+ "ALL_PERMISSIONS", "PERMISSIONS", "Perm", "ROLE_PERMISSIONS",
+ "GLOBAL_ROLES", "TEAM_ROLES", "GLOBAL_ROLE_NAMES", "TEAM_ROLE_NAMES",
+ "CANONICAL_ROLE_NAMES", "BASELINE_GLOBAL_ROLE", "Ctx", "build_ctx",
+ "resolve_permissions", "can_access_team", "global_role_rank",
]
-ALL_PERMISSIONS = [p[0] for p in PERMISSIONS]
# ββ Roles (name, label, rank) ββββββββββββββββββββββββββββββββββββββββββββββββ
# Exactly three assignable roles exist, with fixed (non-editable) permission sets:
@@ -65,14 +59,14 @@ def global_role_rank(role: str) -> int:
# ββ Fixed role β permission sets (source of truth for seeding) βββββββββββββββ
-_VIEWER = {"subscriptions.view", "teams.view"}
+_VIEWER = {Perm.SUB_VIEW, Perm.TEAMS_VIEW}
ROLE_PERMISSIONS = {
"super_admin": set(ALL_PERMISSIONS),
- "team_admin": _VIEWER | {"subscriptions.create", "subscriptions.edit",
- "subscriptions.delete", "subscriptions.delete.permanent",
- "records.restore", "records.view_deleted",
- "teams.manage", "audit.view"},
+ "team_admin": _VIEWER | {Perm.SUB_CREATE, Perm.SUB_EDIT,
+ Perm.SUB_DELETE, Perm.SUB_DELETE_PERMANENT,
+ Perm.RECORDS_RESTORE, Perm.RECORDS_VIEW_DELETED,
+ Perm.TEAMS_MANAGE, Perm.AUDIT_VIEW},
"viewer": _VIEWER,
}
@@ -82,13 +76,15 @@ def global_role_rank(role: str) -> int:
class Ctx:
"""Per-request identity + authorization context, built by the Beforeware."""
- def __init__(self, user, active_team_id, active_team_name, teams, perms, view_all):
+ def __init__(self, user, active_team_id, active_team_name, teams, perms, view_all,
+ csrf_token=""):
self.user = user # users row (dict)
self.active_team_id = active_team_id # int | None
self.active_team_name = active_team_name
self.teams = teams # [{id, name, (team_role)}] the user may switch among
self.perms = perms # set[str]
self.view_all = view_all # super_admin cross-team mode
+ self.csrf_token = csrf_token # per-session CSRF token (for )
@property
def username(self):
@@ -142,7 +138,8 @@ def build_ctx(db, user: dict, session: dict) -> Ctx:
active_name = next((t["name"] for t in teams if t["id"] == active), None)
perms = resolve_permissions(db, user, active, view_all)
- return Ctx(user, active, active_name, teams, perms, view_all)
+ return Ctx(user, active, active_name, teams, perms, view_all,
+ csrf_token=ensure_token(session))
def can_access_team(db, user: dict, team_id: int) -> bool:
diff --git a/app/routes/__init__.py b/app/routes/__init__.py
index 4541c12..2d23d30 100644
--- a/app/routes/__init__.py
+++ b/app/routes/__init__.py
@@ -3,8 +3,15 @@
"""
from app.routes import (
- auth_routes, dashboard, manage, subscriptions, import_export, audit_routes,
- users, teams, admin,
+ admin,
+ audit_routes,
+ auth_routes,
+ dashboard,
+ import_export,
+ manage,
+ subscriptions,
+ teams,
+ users,
)
# Order is cosmetic; route paths are unique across modules.
diff --git a/app/routes/admin.py b/app/routes/admin.py
index fd2791e..cc5a845 100644
--- a/app/routes/admin.py
+++ b/app/routes/admin.py
@@ -5,16 +5,29 @@
from fasthtml.common import *
-from app.db import (
- get_db, get_all_subscriptions, get_subscription, get_all_users,
- get_periods, get_periods_map, current_price,
- restore_subscription, purge_subscription, audit,
-)
from app.authz import require
from app.components import (
- page_title, nav_bar, alert, badge, fmt_eur, category_label,
+ alert,
+ badge,
+ category_label,
+ fmt_eur,
+ nav_bar,
+ page_title,
+)
+from app.db import (
+ audit,
+ current_price,
+ get_all_subscriptions,
+ get_all_users,
+ get_db,
+ get_periods,
+ get_periods_map,
+ get_subscription,
+ purge_subscription,
+ restore_subscription,
)
-from app.styles import PAGE_HEADER, TABLE, MUTED, btn
+from app.permissions import Perm
+from app.styles import MUTED, PAGE_HEADER, TABLE, btn
ar = APIRouter()
@@ -24,13 +37,13 @@
@ar("/admin/deleted")
def get(req, session, msg: str = "", msg_kind: str = "warning"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "records.view_deleted")): return r
+ if (r := require(ctx, Perm.RECORDS_VIEW_DELETED)): return r
db = get_db()
deleted = get_all_subscriptions(db, ctx, only_deleted=True)
periods_map = get_periods_map(db, [s["id"] for s in deleted])
user_names = {u["id"]: u["username"] for u in get_all_users(db)}
- can_restore = ctx.can("records.restore")
- can_purge = ctx.can("subscriptions.delete.permanent")
+ can_restore = ctx.can(Perm.RECORDS_RESTORE)
+ can_purge = ctx.can(Perm.SUB_DELETE_PERMANENT)
def actions(s):
btns = []
@@ -80,7 +93,7 @@ def actions(s):
@ar("/admin/deleted/subscription/{sub_id}/restore")
async def post(req, session, sub_id: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "records.restore")): return r
+ if (r := require(ctx, Perm.RECORDS_RESTORE)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id, include_deleted=True)
if not sub or sub.get("deleted_at") is None:
@@ -95,7 +108,7 @@ async def post(req, session, sub_id: int):
@ar("/admin/deleted/subscription/{sub_id}/purge")
async def post(req, session, sub_id: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.delete.permanent")): return r
+ if (r := require(ctx, Perm.SUB_DELETE_PERMANENT)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id, include_deleted=True)
if not sub:
diff --git a/app/routes/audit_routes.py b/app/routes/audit_routes.py
index 04cd5dd..df42188 100644
--- a/app/routes/audit_routes.py
+++ b/app/routes/audit_routes.py
@@ -4,12 +4,18 @@
from fasthtml.common import *
-from app.db import get_db, get_audit_log
from app.authz import require
from app.components import (
- page_title, nav_bar, alert, badge, pagination_bar, json_pretty, select_menu,
+ badge,
+ json_pretty,
+ nav_bar,
+ page_title,
+ pagination_bar,
+ select_menu,
)
-from app.styles import PAGE_HEADER, TABLE, MUTED, btn
+from app.db import get_audit_log, get_db
+from app.permissions import Perm
+from app.styles import MUTED, PAGE_HEADER, TABLE, btn
ar = APIRouter()
@@ -17,7 +23,7 @@
@ar("/audit")
def get(req, session, action_filter: str = "", page: int = 1):
ctx = req.scope["ctx"]
- if (r := require(ctx, "audit.view")): return r
+ if (r := require(ctx, Perm.AUDIT_VIEW)): return r
db = get_db()
entries, total = get_audit_log(db, ctx,
@@ -26,7 +32,7 @@ def get(req, session, action_filter: str = "", page: int = 1):
actions = ["LOGIN", "LOGOUT", "CREATE", "UPDATE", "DELETE", "PRICE_CHANGE",
"RESTORE", "PERMANENT_DELETE", "ROLE_CHANGE", "TEAM_CREATE", "MEMBER_ADD"]
scope_note = ("all teams" if (ctx.view_all and ctx.is_super) else
- (f"team: {ctx.active_team_name}" if ctx.can("audit.view") and ctx.active_team_name
+ (f"team: {ctx.active_team_name}" if ctx.can(Perm.AUDIT_VIEW) and ctx.active_team_name
else "your actions"))
filter_bar = Form(
diff --git a/app/routes/auth_routes.py b/app/routes/auth_routes.py
index 7483b91..df28819 100644
--- a/app/routes/auth_routes.py
+++ b/app/routes/auth_routes.py
@@ -2,18 +2,29 @@
auth_routes.py β First-run setup, login, logout.
"""
+from urllib.parse import quote_plus
+
from fasthtml.common import *
+from app.auth import authenticate, create_user
+from app.components import alert, page_title
+from app.csrf import csrf_meta
from app.db import (
- init_db, has_any_users, get_user_by_id, get_db,
- create_team, add_member, write_audit_log,
+ add_member,
+ create_team,
+ get_db,
+ get_user_by_id,
+ has_any_users,
+ init_db,
+ write_audit_log,
)
-from app.auth import authenticate, create_user
-from app.components import page_title, alert
-from app.styles import INPUT, CARD, FIELD, btn
+from app.ratelimit import client_key, login_limiter
+from app.styles import CARD, FIELD, INPUT, btn
ar = APIRouter()
+_RATE_LIMIT_MSG = "Too many attempts. Wait a few minutes and try again."
+
# ββ First-run setup (create initial admin) βββββββββββββββββββββββββββββββββββ
@@ -22,7 +33,7 @@ def get(session, error: str = ""):
db = init_db()
if has_any_users(db):
return RedirectResponse("/login", status_code=303)
- return page_title("Setup"), Titled("Welcome to SubTracker",
+ return page_title("Setup"), csrf_meta(session), Titled("Welcome to SubTracker",
Div(
H2("Create your admin account", cls="mb-1"),
alert(error, "error") if error else "",
@@ -44,10 +55,13 @@ def get(session, error: str = ""):
@ar("/setup")
-async def post(session, username: str, password: str, password2: str):
+async def post(req, session, username: str, password: str, password2: str):
db = init_db()
if has_any_users(db):
return RedirectResponse("/login", status_code=303)
+ if login_limiter.is_limited(client_key(req)):
+ return RedirectResponse(f"/setup?error={quote_plus(_RATE_LIMIT_MSG)}", status_code=303)
+ login_limiter.record(client_key(req))
if not username.strip():
return RedirectResponse("/setup?error=Username+cannot+be+empty", status_code=303)
if password != password2:
@@ -79,7 +93,7 @@ def get(session, error: str = ""):
db = init_db()
if not has_any_users(db):
return RedirectResponse("/setup", status_code=303)
- return page_title("Login"), Titled("SubTracker",
+ return page_title("Login"), csrf_meta(session), Titled("SubTracker",
Div(
H2("Sign In", cls="mb-3"),
alert(error, "error") if error else "",
@@ -97,7 +111,10 @@ def get(session, error: str = ""):
@ar("/login")
-async def post(session, username: str, password: str):
+async def post(req, session, username: str, password: str):
+ if login_limiter.is_limited(client_key(req)):
+ return RedirectResponse(f"/login?error={quote_plus(_RATE_LIMIT_MSG)}", status_code=303)
+ login_limiter.record(client_key(req))
user = authenticate(username, password)
if not user:
return RedirectResponse("/login?error=Invalid+username+or+password", status_code=303)
diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py
index 238e28b..bb5544e 100644
--- a/app/routes/dashboard.py
+++ b/app/routes/dashboard.py
@@ -11,20 +11,41 @@
from fasthtml.common import *
from app import timeutil
-from app.db import (
- get_db, get_all_subscriptions, get_periods_map, is_active_on, current_price,
-)
from app.authz import require
+from app.components import (
+ MONTH_LABELS,
+ badge,
+ bar_chart,
+ category_label,
+ fmt_eur,
+ hbar_breakdown,
+ nav_bar,
+ page_title,
+ section_card,
+)
from app.cost_utils import (
- year_cost, monthly_costs_for_year, frequency_label, get_annual_cost,
+ frequency_label,
+ get_annual_cost,
+ monthly_costs_for_year,
+ year_cost,
)
-from app.components import (
- page_title, nav_bar, section_card, badge, fmt_eur, category_label,
- bar_chart, hbar_breakdown, MONTH_LABELS,
+from app.db import (
+ current_price,
+ get_all_subscriptions,
+ get_db,
+ get_periods_map,
+ is_active_on,
)
+from app.permissions import Perm
from app.styles import (
- PAGE_HEADER, COST_CARD, COST_CARDS, COST_LABEL, COST_AMOUNT,
- CHARTS_GRID, LINK, btn,
+ CHARTS_GRID,
+ COST_AMOUNT,
+ COST_CARD,
+ COST_CARDS,
+ COST_LABEL,
+ LINK,
+ PAGE_HEADER,
+ btn,
)
ar = APIRouter()
@@ -123,7 +144,7 @@ def _yoy_badge(cur: float, prev: float, prev_year: int):
@ar("/dashboard")
def get(req, session, year: int = None):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.view")): return r
+ if (r := require(ctx, Perm.SUB_VIEW)): return r
db = get_db()
current_year = timeutil.today().year
diff --git a/app/routes/import_export.py b/app/routes/import_export.py
index bb241f8..428d933 100644
--- a/app/routes/import_export.py
+++ b/app/routes/import_export.py
@@ -20,13 +20,18 @@
from fasthtml.common import *
from app import timeutil
-from app.db import (
- get_db, get_all_subscriptions, get_periods_map, add_period, audit,
-)
from app.authz import require, writable_team
+from app.components import alert, nav_bar, page_title, section_card
from app.cost_utils import normalise_cadence
-from app.components import page_title, nav_bar, section_card, alert
-from app.styles import PAGE_HEADER, LINK, INPUT, MUTED_SM, btn
+from app.db import (
+ add_period,
+ audit,
+ get_all_subscriptions,
+ get_db,
+ get_periods_map,
+)
+from app.permissions import Perm
+from app.styles import LINK, MUTED_SM, PAGE_HEADER, btn
ar = APIRouter()
@@ -109,7 +114,7 @@ def _to_json(subs, periods_map) -> str:
@ar("/export")
def get(req, session, fmt: str = "csv"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.view")): return r
+ if (r := require(ctx, Perm.SUB_VIEW)): return r
db = get_db()
subs, periods_map = _collect(db, ctx)
# Filename-safe local timestamp, e.g. 2026-06-18_143005 (no colons).
@@ -254,7 +259,7 @@ def _result_block(created: int, periods_added: int, errors: list):
def _page(ctx, result=None):
- can_import = ctx.can("subscriptions.create")
+ can_import = ctx.can(Perm.SUB_CREATE)
export_card = section_card(
P("Download every subscription for the current team, including all of its "
@@ -307,14 +312,14 @@ def _page(ctx, result=None):
@ar("/import")
def get(req, session):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.view")): return r
+ if (r := require(ctx, Perm.SUB_VIEW)): return r
return _page(ctx)
@ar("/import")
async def post(req, session):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.create")): return r
+ if (r := require(ctx, Perm.SUB_CREATE)): return r
if not writable_team(ctx):
return _page(ctx, alert("Switch to a specific team (not βAll teamsβ) before "
"importing subscriptions.", "warning"))
diff --git a/app/routes/manage.py b/app/routes/manage.py
index 240190a..1ec4f75 100644
--- a/app/routes/manage.py
+++ b/app/routes/manage.py
@@ -10,17 +10,30 @@
from fasthtml.common import *
from app import timeutil
-from app.db import (
- get_db, get_all_subscriptions, get_categories, get_periods_map,
- current_price, is_active_on, upcoming_price_change,
-)
from app.authz import require
-from app.cost_utils import frequency_label
from app.components import (
- page_title, nav_bar, badge, status_badge, action_menu,
- select_menu, fmt_eur, category_label, pagination_bar,
+ action_menu,
+ badge,
+ category_label,
+ fmt_eur,
+ nav_bar,
+ page_title,
+ pagination_bar,
+ select_menu,
+ status_badge,
+)
+from app.cost_utils import frequency_label
+from app.db import (
+ current_price,
+ get_all_subscriptions,
+ get_categories,
+ get_db,
+ get_periods_map,
+ is_active_on,
+ upcoming_price_change,
)
-from app.styles import PAGE_HEADER, TABLE, CONTROL, btn
+from app.permissions import Perm
+from app.styles import CONTROL, PAGE_HEADER, TABLE, btn
_FF = "grid gap-1.5 text-sm font-medium" # filter field (label + control)
_PER_PAGE = 20 # subscriptions per page in the manage list
@@ -65,13 +78,13 @@ def _price_change_icon(change) -> object:
@ar("/manage")
def get(req, session, q: str = "", status: str = "all", category: str = "", page: int = 1):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.view")): return r
+ if (r := require(ctx, Perm.SUB_VIEW)): return r
db = get_db()
- can_create = ctx.can("subscriptions.create")
- can_edit = ctx.can("subscriptions.edit")
- can_delete = ctx.can("subscriptions.delete")
- can_view_detail = ctx.can("subscriptions.view")
+ can_create = ctx.can(Perm.SUB_CREATE)
+ can_edit = ctx.can(Perm.SUB_EDIT)
+ can_delete = ctx.can(Perm.SUB_DELETE)
+ can_view_detail = ctx.can(Perm.SUB_VIEW)
show_actions = can_edit or can_delete
today = timeutil.today_iso()
diff --git a/app/routes/subscriptions.py b/app/routes/subscriptions.py
index 230d551..79f02f9 100644
--- a/app/routes/subscriptions.py
+++ b/app/routes/subscriptions.py
@@ -13,21 +13,46 @@
from fasthtml.common import *
from app import timeutil
-from app.db import (
- get_db, get_subscription, get_periods, current_price, is_active_on,
- add_period, update_period, delete_period, validate_periods,
- get_audit_for_entity, get_categories, audit,
-)
from app.authz import require, writable_team
+from app.components import (
+ MONTH_LABELS,
+ alert,
+ badge,
+ bar_chart,
+ category_label,
+ collapsible_card,
+ fmt_eur,
+ nav_bar,
+ page_title,
+ section_card,
+ status_badge,
+ subscription_form,
+)
from app.cost_utils import (
- frequency_label, get_period_cost, normalise_cadence,
- upcoming_payments_for_periods, monthly_costs_for_year, year_cost, range_cost,
+ frequency_label,
+ get_period_cost,
+ monthly_costs_for_year,
+ normalise_cadence,
+ range_cost,
+ upcoming_payments_for_periods,
+ year_cost,
)
-from app.components import (
- page_title, nav_bar, section_card, collapsible_card, alert, badge, status_badge,
- fmt_eur, category_label, subscription_form, bar_chart, MONTH_LABELS,
+from app.db import (
+ add_period,
+ audit,
+ current_price,
+ delete_period,
+ get_audit_for_entity,
+ get_categories,
+ get_db,
+ get_periods,
+ get_subscription,
+ is_active_on,
+ update_period,
+ validate_periods,
)
-from app.styles import PAGE_HEADER, TABLE, TABLE_WRAP, INPUT, TEXTAREA, LINK, MUTED_SM, btn
+from app.permissions import Perm
+from app.styles import INPUT, LINK, MUTED_SM, PAGE_HEADER, TABLE, TABLE_WRAP, btn
ar = APIRouter()
@@ -46,7 +71,7 @@ def _detail_redirect(sub_id: int, msg: str = "", kind: str = "warning"):
@ar("/manage/new")
def get(req, session):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.create")): return r
+ if (r := require(ctx, Perm.SUB_CREATE)): return r
if not writable_team(ctx):
return page_title("New Subscription"), nav_bar(ctx, "manage"), Main(
Div(H2("Add Subscription"), A("β Manage", href="/manage", cls=LINK), cls=PAGE_HEADER),
@@ -67,7 +92,7 @@ async def post(req, session, name: str, amount: float, start_date: str,
interval: int = 1, base_unit: str = "", notes: str = "",
category: str = ""):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.create")): return r
+ if (r := require(ctx, Perm.SUB_CREATE)): return r
if not writable_team(ctx):
return RedirectResponse("/teams?msg=Switch+to+a+specific+team+first&msg_kind=warning",
status_code=303)
@@ -116,7 +141,7 @@ async def post(req, session, name: str, amount: float, start_date: str,
@ar("/subscriptions/{sub_id}/edit")
def get(req, session, sub_id: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.edit")): return r
+ if (r := require(ctx, Perm.SUB_EDIT)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
@@ -138,7 +163,7 @@ async def post(req, session, sub_id: int, name: str, frequency: str = "monthly",
interval: int = 1, base_unit: str = "", notes: str = "",
category: str = ""):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.edit")): return r
+ if (r := require(ctx, Perm.SUB_EDIT)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
@@ -164,7 +189,7 @@ async def post(req, session, sub_id: int, name: str, frequency: str = "monthly",
async def post(req, session, sub_id: int, amount: float, start_date: str,
end_date: str = ""):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.edit")): return r
+ if (r := require(ctx, Perm.SUB_EDIT)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
@@ -189,7 +214,7 @@ async def post(req, session, sub_id: int, amount: float, start_date: str,
@ar("/subscriptions/{sub_id}/periods/{period_id}/edit")
def get(req, session, sub_id: int, period_id: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.edit")): return r
+ if (r := require(ctx, Perm.SUB_EDIT)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
@@ -225,7 +250,7 @@ def get(req, session, sub_id: int, period_id: int):
async def post(req, session, sub_id: int, period_id: int, amount: float,
start_date: str, end_date: str = ""):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.edit")): return r
+ if (r := require(ctx, Perm.SUB_EDIT)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
@@ -248,7 +273,7 @@ async def post(req, session, sub_id: int, period_id: int, amount: float,
@ar("/subscriptions/{sub_id}/periods/{period_id}/delete")
async def post(req, session, sub_id: int, period_id: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.edit")): return r
+ if (r := require(ctx, Perm.SUB_EDIT)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
@@ -273,7 +298,7 @@ async def post(req, session, sub_id: int, period_id: int):
@ar("/subscriptions/{sub_id}/detail")
def get(req, session, sub_id: int, msg: str = "", msg_kind: str = "warning"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.view")): return r
+ if (r := require(ctx, Perm.SUB_VIEW)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
@@ -287,8 +312,8 @@ def get(req, session, sub_id: int, msg: str = "", msg_kind: str = "warning"):
audit_entries = get_audit_for_entity(db, sub_id, "subscription")
freq_lbl = frequency_label(sub["frequency"], sub["interval"] or 1, sub.get("base_unit"))
- can_edit = ctx.can("subscriptions.edit")
- can_delete = ctx.can("subscriptions.delete")
+ can_edit = ctx.can(Perm.SUB_EDIT)
+ can_delete = ctx.can(Perm.SUB_DELETE)
actions = []
if can_edit:
actions.append(
@@ -445,7 +470,7 @@ def period_status(p):
Thead(Tr(Th("Time"), Th("Action"), Th("Description"))),
Tbody(*audit_rows), cls=TABLE,
), cls=TABLE_WRAP) if audit_rows else P("No audit entries.", cls=MUTED_SM),
- ) if ctx.can("audit.view") else ""
+ ) if ctx.can(Perm.AUDIT_VIEW) else ""
return page_title(sub["name"]), nav_bar(ctx, "manage"), Main(
Div(H2(sub["name"]), A("β Manage", href="/manage", cls=LINK), cls=PAGE_HEADER),
@@ -459,7 +484,7 @@ def period_status(p):
@ar("/subscriptions/{sub_id}/delete")
async def post(req, session, sub_id: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "subscriptions.delete")): return r
+ if (r := require(ctx, Perm.SUB_DELETE)): return r
db = get_db()
sub = get_subscription(db, ctx, sub_id)
if not sub:
diff --git a/app/routes/teams.py b/app/routes/teams.py
index 3e09607..e965624 100644
--- a/app/routes/teams.py
+++ b/app/routes/teams.py
@@ -8,16 +8,29 @@
from fasthtml.common import *
+from app.authz import require
+from app.components import alert, badge, nav_bar, page_title, section_card, select_menu
from app.db import (
- get_db, list_all_teams, list_user_teams, get_team, get_membership,
- get_membership_by_id, list_team_members, member_count, count_team_admins,
- create_team, add_member, set_member_role, remove_member, get_all_users,
- get_user_by_id, audit,
+ add_member,
+ audit,
+ count_team_admins,
+ create_team,
+ get_all_users,
+ get_db,
+ get_membership,
+ get_membership_by_id,
+ get_team,
+ get_user_by_id,
+ list_all_teams,
+ list_team_members,
+ list_user_teams,
+ member_count,
+ remove_member,
+ set_member_role,
)
-from app.authz import require
-from app.rbac import TEAM_ROLES, TEAM_ROLE_NAMES, can_access_team
-from app.components import page_title, nav_bar, section_card, alert, badge, select_menu
-from app.styles import PAGE_HEADER, TABLE, INPUT, LINK, MUTED, btn
+from app.permissions import Perm
+from app.rbac import TEAM_ROLE_NAMES, TEAM_ROLES, can_access_team
+from app.styles import INPUT, LINK, MUTED, PAGE_HEADER, TABLE, btn
ar = APIRouter()
@@ -43,9 +56,9 @@ def _team_role_select(name: str, current: str = "viewer", width: str = "w-[130px
@ar("/teams")
def get(req, session, msg: str = "", msg_kind: str = "warning"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "teams.manage")): return r
+ if (r := require(ctx, Perm.TEAMS_MANAGE)): return r
db = get_db()
- can_create = ctx.can("teams.manage")
+ can_create = ctx.can(Perm.TEAMS_MANAGE)
teams = list_all_teams(db) if ctx.is_global_admin else list_user_teams(db, ctx.user["id"])
rows = []
@@ -103,7 +116,7 @@ def get(req, session, msg: str = "", msg_kind: str = "warning"):
@ar("/teams/switch")
async def post(req, session, team_id: str = ""):
ctx = req.scope["ctx"]
- if (r := require(ctx, "teams.view")): return r
+ if (r := require(ctx, Perm.TEAMS_VIEW)): return r
db = get_db()
if team_id == "__all__" and ctx.is_super:
session["view_all"] = True
@@ -133,7 +146,7 @@ async def post(req, session):
@ar("/teams/new")
async def post(req, session, name: str, description: str = ""):
ctx = req.scope["ctx"]
- if (r := require(ctx, "teams.manage")): return r
+ if (r := require(ctx, Perm.TEAMS_MANAGE)): return r
db = get_db()
if not name.strip():
return RedirectResponse("/teams?msg=Team+name+required", status_code=303)
@@ -153,7 +166,7 @@ async def post(req, session, name: str, description: str = ""):
@ar("/teams/{team_id}/members")
def get(req, session, team_id: int, msg: str = "", msg_kind: str = "warning"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "teams.manage")): return r
+ if (r := require(ctx, Perm.TEAMS_MANAGE)): return r
db = get_db()
team = get_team(db, team_id)
if not team or not can_manage_team(db, ctx, team_id):
@@ -218,7 +231,7 @@ def get(req, session, team_id: int, msg: str = "", msg_kind: str = "warning"):
@ar("/teams/{team_id}/members/add")
async def post(req, session, team_id: int, user_id: int, team_role: str = "viewer"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "teams.manage")): return r
+ if (r := require(ctx, Perm.TEAMS_MANAGE)): return r
db = get_db()
team = get_team(db, team_id)
if not team or not can_manage_team(db, ctx, team_id):
@@ -238,7 +251,7 @@ async def post(req, session, team_id: int, user_id: int, team_role: str = "viewe
@ar("/teams/{team_id}/members/{membership_id}/role")
async def post(req, session, team_id: int, membership_id: int, team_role: str):
ctx = req.scope["ctx"]
- if (r := require(ctx, "teams.manage")): return r
+ if (r := require(ctx, Perm.TEAMS_MANAGE)): return r
db = get_db()
team = get_team(db, team_id)
if not team or not can_manage_team(db, ctx, team_id):
@@ -263,7 +276,7 @@ async def post(req, session, team_id: int, membership_id: int, team_role: str):
@ar("/teams/{team_id}/members/{membership_id}/remove")
async def post(req, session, team_id: int, membership_id: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "teams.manage")): return r
+ if (r := require(ctx, Perm.TEAMS_MANAGE)): return r
db = get_db()
team = get_team(db, team_id)
if not team or not can_manage_team(db, ctx, team_id):
diff --git a/app/routes/users.py b/app/routes/users.py
index 293f9c0..66960e1 100644
--- a/app/routes/users.py
+++ b/app/routes/users.py
@@ -8,14 +8,21 @@
from fasthtml.common import *
+from app.authz import require
+from app.components import alert, badge, nav_bar, page_title, select_menu
from app.db import (
- get_db, get_all_users, get_user_by_id, username_taken,
- count_super_admins, set_global_role, soft_delete_user, audit,
+ audit,
+ count_super_admins,
+ get_all_users,
+ get_db,
+ get_user_by_id,
+ set_global_role,
+ soft_delete_user,
+ username_taken,
)
-from app.authz import require
+from app.permissions import Perm
from app.rbac import GLOBAL_ROLE_NAMES, global_role_rank
-from app.components import page_title, nav_bar, alert, badge, select_menu
-from app.styles import PAGE_HEADER, TABLE, INPUT, MUTED, btn
+from app.styles import INPUT, MUTED, PAGE_HEADER, TABLE, btn
ar = APIRouter()
@@ -32,9 +39,9 @@ def _role_select(name: str, current: str, width: str = "w-[140px]"):
@ar("/users")
def get(req, session, msg: str = "", msg_kind: str = "warning"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "users.view")): return r
+ if (r := require(ctx, Perm.USERS_VIEW)): return r
db = get_db()
- can_manage = ctx.can("users.manage")
+ can_manage = ctx.can(Perm.USERS_MANAGE)
all_users = get_all_users(db)
def row(u):
@@ -98,7 +105,7 @@ def row(u):
@ar("/users/new")
async def post(req, session, username: str, password: str, global_role: str = "user"):
ctx = req.scope["ctx"]
- if (r := require(ctx, "users.manage")): return r
+ if (r := require(ctx, Perm.USERS_MANAGE)): return r
db = get_db()
uname = username.strip()
role = global_role if global_role in GLOBAL_ROLE_NAMES else "user"
@@ -119,7 +126,7 @@ async def post(req, session, username: str, password: str, global_role: str = "u
@ar("/users/{uid}/role")
async def post(req, session, uid: int, global_role: str):
ctx = req.scope["ctx"]
- if (r := require(ctx, "users.manage")): return r
+ if (r := require(ctx, Perm.USERS_MANAGE)): return r
db = get_db()
if uid == ctx.user["id"]:
return RedirectResponse("/users?msg=Cannot+change+your+own+role", status_code=303)
@@ -146,7 +153,7 @@ async def post(req, session, uid: int, global_role: str):
@ar("/users/{uid}/delete")
async def post(req, session, uid: int):
ctx = req.scope["ctx"]
- if (r := require(ctx, "users.manage")): return r
+ if (r := require(ctx, Perm.USERS_MANAGE)): return r
db = get_db()
if uid == ctx.user["id"]:
return RedirectResponse("/users?msg=Cannot+delete+yourself", status_code=303)
diff --git a/app/session.py b/app/session.py
index 92784b8..ff1ada3 100644
--- a/app/session.py
+++ b/app/session.py
@@ -8,7 +8,7 @@
from starlette.responses import RedirectResponse
-from app.db import has_any_users, get_user_by_id, get_db
+from app.db import get_db, get_user_by_id, has_any_users
from app.rbac import build_ctx
# Paths the auth gate does NOT run on (public pages + top-level static assets).
diff --git a/pyproject.toml b/pyproject.toml
index 595f83b..6cda7c5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,10 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.0",
+ "pytest-cov>=5.0",
"httpx>=0.27",
+ "ruff>=0.6",
+ "mypy>=1.11",
]
[tool.pytest.ini_options]
@@ -23,3 +26,52 @@ addopts = "-q"
# Put the repo root on sys.path so `app` imports under a plain `pytest` /
# `uv run pytest` invocation (not just `python -m pytest`).
pythonpath = ["."]
+
+[tool.coverage.run]
+source = ["app"]
+omit = ["app/styles.py"]
+
+[tool.coverage.report]
+show_missing = true
+
+[tool.ruff]
+target-version = "py313"
+line-length = 100
+extend-exclude = ["subtracker", "subtracker-nightly"]
+
+[tool.ruff.lint]
+# pycodestyle errors, pyflakes, import sorting, bugbear, comprehensions, pyupgrade.
+select = ["E", "F", "I", "B", "C4", "UP"]
+ignore = [
+ "E501", # line length is handled by the formatter, not enforced here
+ "F403", "F405", # `from fasthtml.common import *` is the framework's intended usage
+ "B008", # function calls in arg defaults are fine for FT component factories
+ "E701", # compact `if cond: return` guards are a deliberate house style
+ "B026", # `f(kw=v, *args)` star-after-keyword is used by FT component calls
+ "B905", # not adopting explicit `zip(strict=)` across the codebase
+]
+
+[tool.ruff.lint.per-file-ignores]
+# Route modules define many same-named handlers (`get`/`post`) on one APIRouter β
+# FastHTML's intended pattern, which pyflakes reads as redefinition.
+"app/routes/*.py" = ["F811"]
+
+[tool.mypy]
+python_version = "3.13"
+# FastHTML builds its HTML tags (Div, H2, Form, β¦) dynamically, so mypy can't
+# resolve names from `from fasthtml.common import *`. Type-checking is therefore
+# scoped to the FT-free logic/data layer β where type bugs actually bite β and
+# imported view/framework modules are followed but not reported.
+files = [
+ "app/auth.py", "app/authz.py", "app/config.py", "app/cost_utils.py",
+ "app/ratelimit.py", "app/rbac.py", "app/session.py", "app/timeutil.py",
+ "app/db",
+]
+follow_imports = "silent"
+ignore_missing_imports = true
+check_untyped_defs = true
+warn_redundant_casts = true
+warn_unused_ignores = true
+# sqlite-utils types `db["table"]` as `Table | View`; the `.insert/.create/...`
+# table methods are correct at runtime but trip union-attr across the data layer.
+disable_error_code = ["union-attr"]
diff --git a/tests/conftest.py b/tests/conftest.py
index d0fbff0..dd32991 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -7,6 +7,7 @@
"""
import os
+import re
# Set before any `app` import so config-time reads pick them up.
os.environ.setdefault("SUBTRACKER_SECRET", "test-secret-not-for-prod")
@@ -14,6 +15,44 @@
import pytest
+_CSRF_META_RE = re.compile(r'name="csrf-token"\s+content="([^"]*)"')
+
+
+def csrf_token(client, path: str = "/login") -> str:
+ """Scrape the per-session CSRF token from a rendered page's tag."""
+ html = client.get(path, follow_redirects=True).text
+ m = _CSRF_META_RE.search(html)
+ return m.group(1) if m else ""
+
+
+def post(client, url: str, data: dict | None = None, token_path: str = "/login", **kwargs):
+ """POST with the session's CSRF token injected (as the client JS would)."""
+ data = dict(data or {})
+ data.setdefault("csrf_token", csrf_token(client, token_path))
+ return client.post(url, data=data, **kwargs)
+
+
+def setup_first_admin(client, username: str = "admin", password: str = "password123"):
+ """Run first-run /setup, leaving `client` logged in as the super admin."""
+ return post(client, "/setup",
+ {"username": username, "password": password, "password2": password},
+ token_path="/setup", follow_redirects=True)
+
+
+def login(client, username: str, password: str):
+ """Log `client` in over HTTP (sets the session cookie)."""
+ return post(client, "/login", {"username": username, "password": password},
+ follow_redirects=True)
+
+
+@pytest.fixture(autouse=True)
+def _reset_rate_limiter():
+ """Clear the process-global auth rate limiter so tests don't bleed into each other."""
+ from app.ratelimit import login_limiter
+ login_limiter.reset()
+ yield
+ login_limiter.reset()
+
@pytest.fixture
def db_path(tmp_path, monkeypatch):
@@ -34,6 +73,7 @@ def db(db_path):
def client(db):
"""A Starlette TestClient bound to the per-test database."""
from starlette.testclient import TestClient
+
from app.main import app
return TestClient(app)
@@ -42,7 +82,7 @@ def client(db):
def seed_team(db):
"""A super-admin user + a team they administer. Returns (uid, team_id)."""
from app.auth import create_user
- from app.db import create_team, add_member
+ from app.db import add_member, create_team
uid = create_user("admin", "password123", global_role="super_admin")
team_id = create_team(db, "Acme", "Acme team", created_by=uid)
add_member(db, team_id, uid, "team_admin", created_by=uid)
diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py
index 1eb8525..c85a01d 100644
--- a/tests/test_app_smoke.py
+++ b/tests/test_app_smoke.py
@@ -1,6 +1,7 @@
"""End-to-end HTTP smoke tests through the real ASGI app (isolated DB per test)."""
from app.auth import create_user
+from tests.conftest import post
def test_fresh_install_redirects_to_setup(client):
@@ -10,27 +11,27 @@ def test_fresh_install_redirects_to_setup(client):
def test_setup_creates_admin_and_logs_in(client):
- r = client.post("/setup", data={
+ r = post(client, "/setup", {
"username": "admin",
"password": "password123",
"password2": "password123",
- }, follow_redirects=True)
+ }, token_path="/setup", follow_redirects=True)
assert r.status_code == 200
assert r.url.path == "/dashboard"
def test_setup_rejects_mismatched_passwords(client):
- r = client.post("/setup", data={
+ r = post(client, "/setup", {
"username": "admin", "password": "aaaaaa", "password2": "bbbbbb",
- }, follow_redirects=True)
+ }, token_path="/setup", follow_redirects=True)
assert r.url.path == "/setup"
assert "do not match" in r.text.lower()
def test_setup_rejects_short_password(client):
- r = client.post("/setup", data={
+ r = post(client, "/setup", {
"username": "admin", "password": "short", "password2": "short",
- }, follow_redirects=True)
+ }, token_path="/setup", follow_redirects=True)
assert r.url.path == "/setup"
assert "at least 6" in r.text.lower()
@@ -45,15 +46,15 @@ def test_protected_page_redirects_to_login_when_unauthenticated(client, db):
def test_bad_login_is_rejected(client, db):
create_user("admin", "password123", global_role="super_admin")
- r = client.post("/login", data={"username": "admin", "password": "wrong"},
- follow_redirects=True)
+ r = post(client, "/login", {"username": "admin", "password": "wrong"},
+ follow_redirects=True)
assert r.url.path == "/login"
assert "invalid" in r.text.lower()
def test_login_then_reach_dashboard(client, db):
create_user("admin", "password123", global_role="super_admin")
- r = client.post("/login", data={"username": "admin", "password": "password123"},
- follow_redirects=True)
+ r = post(client, "/login", {"username": "admin", "password": "password123"},
+ follow_redirects=True)
assert r.status_code == 200
assert r.url.path == "/dashboard"
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 49bbd3c..86c9592 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,6 +1,6 @@
"""Password hashing + authentication."""
-from app.auth import hash_password, verify_password, create_user, authenticate
+from app.auth import authenticate, create_user, hash_password, verify_password
def test_hash_is_salted_and_verifies():
diff --git a/tests/test_cost_utils.py b/tests/test_cost_utils.py
index 28d48d9..1cec6a5 100644
--- a/tests/test_cost_utils.py
+++ b/tests/test_cost_utils.py
@@ -6,7 +6,6 @@
from app import cost_utils as cu
-
# ββ normalise_cadence ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def test_named_preset_drops_interval_and_base_unit():
diff --git a/tests/test_csrf.py b/tests/test_csrf.py
new file mode 100644
index 0000000..5cb8f08
--- /dev/null
+++ b/tests/test_csrf.py
@@ -0,0 +1,46 @@
+"""CSRF protection: unsafe requests need a token matching the session."""
+
+from app.auth import create_user
+from tests.conftest import csrf_token, login, post
+
+
+def test_post_without_token_is_rejected(client, db):
+ create_user("admin", "password123", global_role="super_admin")
+ # A raw POST with no csrf_token field and no header β must be blocked.
+ r = client.post("/login", data={"username": "admin", "password": "password123"})
+ assert r.status_code == 403
+ assert "csrf" in r.text.lower()
+
+
+def test_post_with_wrong_token_is_rejected(client, db):
+ create_user("admin", "password123", global_role="super_admin")
+ r = client.post("/login", data={"username": "admin", "password": "password123",
+ "csrf_token": "not-the-real-token"})
+ assert r.status_code == 403
+
+
+def test_post_with_valid_token_succeeds(client, db):
+ create_user("admin", "password123", global_role="super_admin")
+ r = login(client, "admin", "password123") # helper injects the session token
+ assert r.status_code == 200
+ assert r.url.path == "/dashboard"
+
+
+def test_htmx_header_token_is_accepted(client, db):
+ """HTMX requests carry the token in the X-CSRFToken header, not the body."""
+ create_user("admin", "password123", global_role="super_admin")
+ login(client, "admin", "password123")
+ token = csrf_token(client, "/manage")
+ # Deleting a non-existent subscription still passes the CSRF gate (then 303s),
+ # proving the header path is accepted; a missing header would 403 instead.
+ r = client.post("/subscriptions/99999/delete", headers={"X-CSRFToken": token})
+ assert r.status_code != 403
+
+
+def test_pages_expose_csrf_meta_tag(client):
+ """Every rendered page carries the token for the client JS to read."""
+ assert csrf_token(client, "/setup") # setup page (no users yet)
+ post(client, "/setup", {"username": "a", "password": "password123",
+ "password2": "password123"},
+ token_path="/setup", follow_redirects=True)
+ assert csrf_token(client, "/dashboard") # authenticated page via nav bar
diff --git a/tests/test_http_rbac.py b/tests/test_http_rbac.py
new file mode 100644
index 0000000..a0b0d07
--- /dev/null
+++ b/tests/test_http_rbac.py
@@ -0,0 +1,64 @@
+"""Permission enforcement at the HTTP layer (not just the resolver)."""
+
+import pytest
+
+from app.auth import create_user
+from app.db import add_member, create_team
+from tests.conftest import login, post
+
+
+@pytest.fixture
+def team(db):
+ """A team owned by a super admin; returns its id."""
+ owner = create_user("owner", "password123", global_role="super_admin")
+ return create_team(db, "Acme", "", created_by=owner)
+
+
+def _member(db, team_id, username, role):
+ uid = create_user(username, "password123", global_role="user")
+ add_member(db, team_id, uid, role, created_by=uid)
+ return uid
+
+
+def test_viewer_cannot_open_new_subscription_form(client, db, team):
+ _member(db, team, "vi", "viewer")
+ login(client, "vi", "password123")
+ r = client.get("/manage/new")
+ assert "not authorized" in r.text.lower()
+
+
+def test_viewer_cannot_create_subscription(client, db, team):
+ _member(db, team, "vi", "viewer")
+ login(client, "vi", "password123")
+ r = post(client, "/manage/new", {
+ "name": "Sneaky", "amount": "9.99", "start_date": "2026-01-01",
+ }, token_path="/manage", follow_redirects=True)
+ assert "not authorized" in r.text.lower()
+ assert db["subscriptions"].count == 0
+
+
+def test_viewer_manage_page_hides_create_and_actions(client, db, team):
+ _member(db, team, "vi", "viewer")
+ login(client, "vi", "password123")
+ r = client.get("/manage")
+ assert r.status_code == 200
+ assert "/manage/new" not in r.text # no Add link
+ assert "Actions" not in r.text # no per-row actions column
+
+
+def test_team_admin_can_open_form_and_create(client, db, team):
+ _member(db, team, "ta", "team_admin")
+ login(client, "ta", "password123")
+ assert "Add Subscription" in client.get("/manage/new").text
+ post(client, "/manage/new", {
+ "name": "Netflix", "amount": "12.99", "start_date": "2026-01-01",
+ "frequency": "monthly",
+ }, token_path="/manage", follow_redirects=True)
+ assert db["subscriptions"].count == 1
+
+
+def test_viewer_cannot_reach_admin_or_users_pages(client, db, team):
+ _member(db, team, "vi", "viewer")
+ login(client, "vi", "password123")
+ assert "not authorized" in client.get("/admin/deleted").text.lower()
+ assert "not authorized" in client.get("/users").text.lower()
diff --git a/tests/test_import_export.py b/tests/test_import_export.py
new file mode 100644
index 0000000..ccfdf0a
--- /dev/null
+++ b/tests/test_import_export.py
@@ -0,0 +1,60 @@
+"""Import / export round-trips through the real HTTP endpoints."""
+
+import json
+
+from tests.conftest import csrf_token, post, setup_first_admin
+
+
+def _create(client, name, amount="10.00"):
+ return post(client, "/manage/new",
+ {"name": name, "amount": amount, "start_date": "2026-01-01",
+ "frequency": "monthly"},
+ token_path="/manage", follow_redirects=True)
+
+
+def _import_file(client, filename, body, media):
+ token = csrf_token(client, "/import")
+ return client.post("/import",
+ files={"file": (filename, body, media)},
+ data={"csrf_token": token}, follow_redirects=True)
+
+
+def test_json_export_contains_subscriptions(client, db):
+ setup_first_admin(client)
+ _create(client, "Netflix")
+ _create(client, "Spotify")
+
+ payload = json.loads(client.get("/export?fmt=json").text)
+ names = {s["name"] for s in payload["subscriptions"]}
+ assert names == {"Netflix", "Spotify"}
+ assert payload["version"] == 1
+
+
+def test_json_round_trip_recreates_subscriptions(client, db):
+ setup_first_admin(client)
+ _create(client, "Netflix")
+ body = client.get("/export?fmt=json").content
+
+ r = _import_file(client, "export.json", body, "application/json")
+ assert "imported 1 subscription" in r.text.lower()
+ # Original + re-imported copy.
+ assert db["subscriptions"].count == 2
+ assert [s["name"] for s in db["subscriptions"].rows] == ["Netflix", "Netflix"]
+
+
+def test_csv_round_trip_recreates_subscriptions(client, db):
+ setup_first_admin(client)
+ _create(client, "Spotify")
+ body = client.get("/export?fmt=csv").content
+
+ r = _import_file(client, "export.csv", body, "text/csv")
+ assert "imported 1 subscription" in r.text.lower()
+ assert db["subscriptions"].count == 2
+
+
+def test_import_reports_errors_for_bad_rows(client, db):
+ setup_first_admin(client)
+ bad = b"name,amount,start_date\n,5.00,2026-01-01\nGood,notanumber,2026-01-01\n"
+ r = _import_file(client, "bad.csv", bad, "text/csv")
+ # The nameless row and the non-numeric amount are both reported.
+ assert "issue" in r.text.lower() or "no subscriptions" in r.text.lower()
diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py
new file mode 100644
index 0000000..33cbbc7
--- /dev/null
+++ b/tests/test_lifecycle.py
@@ -0,0 +1,58 @@
+"""Subscription lifecycle over HTTP: create β edit β soft-delete β restore."""
+
+from tests.conftest import post, setup_first_admin
+
+
+def _create(client, **over):
+ data = {"name": "Netflix", "amount": "12.99", "start_date": "2026-01-01",
+ "frequency": "monthly"}
+ data.update(over)
+ return post(client, "/manage/new", data, token_path="/manage", follow_redirects=True)
+
+
+def test_full_lifecycle(client, db):
+ setup_first_admin(client) # super admin + Default team, logged in
+
+ _create(client, name="Netflix")
+ sub = next(iter(db["subscriptions"].rows))
+ sub_id = sub["id"]
+ assert sub["name"] == "Netflix"
+ assert db["subscription_periods"].count == 1 # first period created
+
+ # Edit identity/cadence.
+ post(client, f"/subscriptions/{sub_id}/edit",
+ {"name": "Netflix Premium", "frequency": "monthly"},
+ token_path="/manage", follow_redirects=True)
+ assert db["subscriptions"].get(sub_id)["name"] == "Netflix Premium"
+
+ # Soft-delete: row stays but is marked deleted.
+ post(client, f"/subscriptions/{sub_id}/delete", token_path="/manage",
+ follow_redirects=True)
+ assert db["subscriptions"].get(sub_id)["deleted_at"] is not None
+
+ # It disappears from the manage list...
+ assert "Netflix Premium" not in client.get("/manage").text
+ # ...but appears under deleted records...
+ assert "Netflix Premium" in client.get("/admin/deleted").text
+
+ # Restore: deleted markers cleared, visible again.
+ post(client, f"/admin/deleted/subscription/{sub_id}/restore",
+ token_path="/admin/deleted", follow_redirects=True)
+ assert db["subscriptions"].get(sub_id)["deleted_at"] is None
+ assert "Netflix Premium" in client.get("/manage").text
+
+
+def test_permanent_delete_removes_row_but_keeps_audit(client, db):
+ setup_first_admin(client)
+ _create(client, name="Spotify")
+ sub_id = next(iter(db["subscriptions"].rows))["id"]
+
+ post(client, f"/subscriptions/{sub_id}/delete", token_path="/manage",
+ follow_redirects=True)
+ post(client, f"/admin/deleted/subscription/{sub_id}/purge",
+ token_path="/admin/deleted", follow_redirects=True)
+
+ assert db["subscriptions"].count == 0
+ # Audit history survives the row (no FK back to it).
+ actions = [a["action"] for a in db["audit_log"].rows if a["entity_id"] == sub_id]
+ assert "PERMANENT_DELETE" in actions
diff --git a/tests/test_permissions.py b/tests/test_permissions.py
new file mode 100644
index 0000000..2898995
--- /dev/null
+++ b/tests/test_permissions.py
@@ -0,0 +1,24 @@
+"""The permission catalog and the Perm constants must stay in lock-step."""
+
+from app.permissions import ALL_PERMISSIONS, PERMISSIONS, Perm
+from app.rbac import ROLE_PERMISSIONS
+
+
+def _perm_constants() -> set:
+ return {v for k, v in vars(Perm).items()
+ if not k.startswith("_") and isinstance(v, str)}
+
+
+def test_constants_exactly_cover_the_catalog():
+ assert _perm_constants() == set(ALL_PERMISSIONS)
+
+
+def test_permission_names_are_unique():
+ assert len(ALL_PERMISSIONS) == len(set(ALL_PERMISSIONS))
+ assert len(PERMISSIONS) == len(ALL_PERMISSIONS)
+
+
+def test_role_matrix_only_references_known_permissions():
+ for role, perms in ROLE_PERMISSIONS.items():
+ unknown = perms - set(ALL_PERMISSIONS)
+ assert not unknown, f"{role} references unknown permissions: {unknown}"
diff --git a/tests/test_ratelimit.py b/tests/test_ratelimit.py
new file mode 100644
index 0000000..8179639
--- /dev/null
+++ b/tests/test_ratelimit.py
@@ -0,0 +1,40 @@
+"""Auth rate limiting: the RateLimiter unit + the /login integration."""
+
+from app.auth import create_user
+from app.ratelimit import RateLimiter, login_limiter
+from tests.conftest import post
+
+
+def test_limiter_allows_up_to_max_then_blocks():
+ rl = RateLimiter(max_attempts=3, window_seconds=300)
+ for _ in range(3):
+ assert not rl.is_limited("ip")
+ rl.record("ip")
+ assert rl.is_limited("ip")
+
+
+def test_limiter_is_per_key():
+ rl = RateLimiter(max_attempts=1, window_seconds=300)
+ rl.record("a")
+ assert rl.is_limited("a")
+ assert not rl.is_limited("b")
+
+
+def test_reset_clears_state():
+ rl = RateLimiter(max_attempts=1, window_seconds=300)
+ rl.record("a")
+ rl.reset("a")
+ assert not rl.is_limited("a")
+
+
+def test_login_is_throttled_after_too_many_attempts(client, db):
+ create_user("admin", "password123", global_role="super_admin")
+ # Exhaust the allowance with wrong passwords.
+ for _ in range(login_limiter.max_attempts):
+ post(client, "/login", {"username": "admin", "password": "wrong"},
+ follow_redirects=True)
+ # The next attempt β even with the CORRECT password β is rate-limited.
+ r = post(client, "/login", {"username": "admin", "password": "password123"},
+ follow_redirects=True)
+ assert r.url.path == "/login"
+ assert "too many attempts" in r.text.lower()
diff --git a/tests/test_rbac.py b/tests/test_rbac.py
index 2a544a9..e251ae3 100644
--- a/tests/test_rbac.py
+++ b/tests/test_rbac.py
@@ -1,9 +1,13 @@
"""Role-based access control: effective-permission resolution + context building."""
from app.auth import create_user
-from app.db import create_team, add_member, get_user_by_id
+from app.db import add_member, create_team, get_user_by_id
from app.rbac import (
- resolve_permissions, build_ctx, can_access_team, ALL_PERMISSIONS, ROLE_PERMISSIONS,
+ ALL_PERMISSIONS,
+ ROLE_PERMISSIONS,
+ build_ctx,
+ can_access_team,
+ resolve_permissions,
)
diff --git a/uv.lock b/uv.lock
index ed34f66..022f3b2 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,6 +1,10 @@
version = 1
revision = 3
requires-python = ">=3.13"
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15'",
+]
[[package]]
name = "anyio"
@@ -74,6 +78,46 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/77/43b27c14865dd4204ef353b875b4251e270b2518296e90b9bda479776c58/apswutils-0.1.2-py3-none-any.whl", hash = "sha256:9cd73744f9ae83c2e6f4337d4fcb092f5ea2f1814037e9ff7d953e2bc9c8362a", size = 48171, upload-time = "2025-12-18T06:24:31.312Z" },
]
+[[package]]
+name = "ast-serialize"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
+ { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
+ { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
+ { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
+ { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
+ { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
+ { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
+ { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
+ { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
+ { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
+ { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
+ { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
+ { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
+ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
+]
+
[[package]]
name = "bcrypt"
version = "5.0.0"
@@ -195,6 +239,60 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "coverage"
+version = "7.14.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/a3/3834a5564fe8f32154cd7032400d3c2f9c565b2a373fa671f2bbdad6f634/coverage-7.14.2.tar.gz", hash = "sha256:7a2da3d81cfe17c18038c6d98e6592aa9147d596d056119b0ee612c3c8bd5230", size = 923982, upload-time = "2026-06-20T14:49:30.885Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/09/b5b334c27960e7aac0003b96491bada7838dc641099fa64a1a598abf33cd/coverage-7.14.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e5f077641a6713ce9d38df9e85d4fb9e008677fc0775cbaeb32ddfc3b319d4ca", size = 220552, upload-time = "2026-06-20T14:48:11.847Z" },
+ { url = "https://files.pythonhosted.org/packages/79/20/879a000c319b4df7b50e4d688c0f7c0f6b5ac9d7b18848cbc00eabf26efe/coverage-7.14.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0907f39b49ae818fe8af50aaa0f19afbc8ca164aea0865181ca7af17a3ac690b", size = 220919, upload-time = "2026-06-20T14:48:13.397Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b7/326dded4371bab60f42215797944a356e4d81a3cee106121c7f7dd531604/coverage-7.14.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734d47669118d75c28981e562d4530ceb77342d31ffef6def5edd5ad4f05d7b", size = 251917, upload-time = "2026-06-20T14:48:14.931Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/14/b3232ba218a0d1a70883d2675f18ff465de9e8e5e3346e81dc2b079838bd/coverage-7.14.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d9a1b5813d00ea6151f6ccf64d1fa16892771dfdda12ba87162d15ec4ea3e1e", size = 254515, upload-time = "2026-06-20T14:48:16.545Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/7a/d77bcbee1cad71b42776574114b462225cc9125b4982f43da1b66adc850f/coverage-7.14.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f0a80f4c8ac3f774210b1cc1bc0e31e75502f2818dda9a144ff90e702c4d91d", size = 255749, upload-time = "2026-06-20T14:48:18.214Z" },
+ { url = "https://files.pythonhosted.org/packages/86/86/97377937b29e9e44a1529bb20cb74dbcf80ed9006d87d7e742ff69e44b67/coverage-7.14.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e66f3f22d6c1515ce70f2e7c3e9c6f3ff0ff33480125c9f9c53e8f6508e30f", size = 257882, upload-time = "2026-06-20T14:48:19.7Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/a4/0fc8fe68bc505450bb068a2823ac7797bd8495240ccb8b4a5a1da1ee7e62/coverage-7.14.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6a2c37c3114f87ca7f10113756026eecb49656514debad600dcbec21f355ccea", size = 252144, upload-time = "2026-06-20T14:48:21.176Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/4a/450094ddc41ab0d2eb4a0457b3856400ea3329568d1303696e85de099ae6/coverage-7.14.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b16a7959d04b1497281c062c180413565c3f3469211d78799ad5b9a75f67796", size = 253882, upload-time = "2026-06-20T14:48:22.701Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/28/2f6ae6d98265d9aa6bac311c4a93403675905b03aca95dc4373080279d75/coverage-7.14.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6466c6999545cf00c4c142dfcbbf2db396dc735f005dcf8f91d57e351a79472b", size = 251846, upload-time = "2026-06-20T14:48:24.295Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/6e/707281468400794d52874e8fb5e38ff7578a0ff32ed49fe4fe85f192d0fc/coverage-7.14.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c60915ebb8f562317ba5ff6b8c32e25c0882289b201a9f2fb2987f91efd95d8", size = 256002, upload-time = "2026-06-20T14:48:26.015Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/83/5e963120de4011257a950ce4cfb7fc833ddf3fee19db495268d3dec28154/coverage-7.14.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:33b830850488acbcd358c78a4fecfafe7031667b4da8ddff5546295dc962cdeb", size = 251665, upload-time = "2026-06-20T14:48:27.654Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/78/66b482cd525083bcc0bc894c16db79dabac37490065b53b07d6e8ab77202/coverage-7.14.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d0f845539230b8269aec902bc978b0cc403f52f002d18a04492efc943404d0bc", size = 253435, upload-time = "2026-06-20T14:48:29.354Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/61/0663fb8cb530c8b11819b920109694eee95a3b22960a9495be0200f657f1/coverage-7.14.2-cp313-cp313-win32.whl", hash = "sha256:a8ac51a2e441e9119b9395f4d893fbc4934c64c8ba58be9b9eaa85591249e548", size = 222591, upload-time = "2026-06-20T14:48:31.142Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/47/1536d2b009c2848c3682500f497053f4645e70911afe02f594000997831a/coverage-7.14.2-cp313-cp313-win_amd64.whl", hash = "sha256:039b264cdb31c44b48f9821e2afbf8f37df49e0fb837e24a942918b36c567e31", size = 223134, upload-time = "2026-06-20T14:48:32.696Z" },
+ { url = "https://files.pythonhosted.org/packages/28/9a/33ba4f335dd60bb34350318283d784f46018070e67b7d4df7c910ec9d9a0/coverage-7.14.2-cp313-cp313-win_arm64.whl", hash = "sha256:7f2ef591e381cc36b8e53334e1b842c760c520c8a52d01e8626209400e93fe6a", size = 222529, upload-time = "2026-06-20T14:48:34.237Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/bc/120390669817ede714ab141ae0a2a73240fd7354aac992c41dc0bd19570f/coverage-7.14.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7a0d1f026b72d627fa5c8a57cbc86ad209b64aa2a65833c83b290ace5cbee126", size = 220593, upload-time = "2026-06-20T14:48:35.755Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/a3/7f1cfacd76af91e585f7ad689d7168002b444ed2a8ce59f2daaff10089b5/coverage-7.14.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4d2b86f81c1c9310a7e774e3cc9e927a3d0bf583ecbfa01498dd626930025428", size = 220925, upload-time = "2026-06-20T14:48:37.35Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/10/6514b2525bb672eb8b43703e46d061d694111db21efe7609db722df2233f/coverage-7.14.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d76bdc1f9396ae70a55d050cf9743d88141c62ce0a22a3f627fab1d11c2f8bc6", size = 251974, upload-time = "2026-06-20T14:48:39.109Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b4/4533091541c6620ecd68115bbfa1c61265b775618adef3a5fd137f4582e9/coverage-7.14.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cda36d8e7bfd63b3e44e75163265429caa5d935b672b00f71bccc8c010518c64", size = 254479, upload-time = "2026-06-20T14:48:40.871Z" },
+ { url = "https://files.pythonhosted.org/packages/06/af/e251a143d5d106385dbca696c553afab6b69f7f6bc376a34e089cc0b8b32/coverage-7.14.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0904f3b79d7b845bef0715afe1900da634d12b97f05b9479cb472880ca07cb9c", size = 255824, upload-time = "2026-06-20T14:48:42.608Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/53/9e5876e60efbaa79d743d1948a5015ddc05b808db1cd62228acf83e87d43/coverage-7.14.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6795ca4198d6cb7fc2c6163214f6555a6bc5f0ae1e268e76139dec4b37c4499", size = 258139, upload-time = "2026-06-20T14:48:44.263Z" },
+ { url = "https://files.pythonhosted.org/packages/85/5a/d35a4f431fb594e46b81cad4a13b470b017e918f347c1c0b260f7494fa1e/coverage-7.14.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c41e9b60fc0fa57f5d73306417d2f9d668202cca6944f9435878c55a5e7ae213", size = 252002, upload-time = "2026-06-20T14:48:45.961Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e2/f5b304c8139c606c4f1b230d3a257d0c88edfbbdf06c58364f07625dc45c/coverage-7.14.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:419d2aadd5746efc2e9df0f33c05570d8192e6f6a6098ab05acce586f44ce8a5", size = 253832, upload-time = "2026-06-20T14:48:47.582Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bc/bbbd283daa6be4f68aad4ad4066fd39ae98e4174db8c03ab26c5803d6234/coverage-7.14.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1c5d273c5f1411c0d26c4f066c398d4a434b1f97bb5fa409189bedce86d4add4", size = 251799, upload-time = "2026-06-20T14:48:49.42Z" },
+ { url = "https://files.pythonhosted.org/packages/69/8d/0745fceb89c9e5f7dd8ed243d97dc8561b7a95545741e2409d2b34654824/coverage-7.14.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5fe465bc691264adce601527a972990c1174075d86bcbe9968fd20c95e0b1948", size = 256075, upload-time = "2026-06-20T14:48:51.065Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/a0/441d9a5255cf021ab41ee00c014a4607d1c72d5e5bef0a4fdaa5be86a907/coverage-7.14.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6fbb61617af1c56f95d53170ae9fa6c9aef6de1abd02fcc50064bfc672efb18d", size = 251612, upload-time = "2026-06-20T14:48:52.653Z" },
+ { url = "https://files.pythonhosted.org/packages/50/37/3d19c5e32d4a529c068eb296abfa3e455bd2c0f9311ecf26280f408ff8e0/coverage-7.14.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e1eff22b831dfd5694989cc1f0789980f18391f614ac67c851af9a8e6d25e9ba", size = 253270, upload-time = "2026-06-20T14:48:54.3Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b0/54dd13937297518da6d092cc2c39d9340ec2194bdfa92e0a64694d643e23/coverage-7.14.2-cp314-cp314-win32.whl", hash = "sha256:58e91be0a233adef698d3e6be54f10401bb91fd7854c0d4c4d50e0d3711e72f1", size = 222796, upload-time = "2026-06-20T14:48:56.084Z" },
+ { url = "https://files.pythonhosted.org/packages/51/45/7a10e0909919686e335fdd95869cfb222d55243ebff27dc5cf59ca259a1f/coverage-7.14.2-cp314-cp314-win_amd64.whl", hash = "sha256:d8429bf97906bfe6c61f9dbfb3342e0d88120da61939da8bd04f830cc3eab3b8", size = 223285, upload-time = "2026-06-20T14:48:57.729Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/03/9cb197eb4b3d1a2eccb2537c226a93c80522c5b8afc5dd93e1993d7bb021/coverage-7.14.2-cp314-cp314-win_arm64.whl", hash = "sha256:13609d9d77249447aa73357b14831b0f3b95f275026c9ff20dd105f981f53a0c", size = 222712, upload-time = "2026-06-20T14:48:59.413Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/3c/e59f498511080d20bf866b0af9eeab820feb91547dae2084cb9bb7fb0e58/coverage-7.14.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9818486c2bac88ae931df7e04905ee29bef49fd218c00f5f02bed4855254a101", size = 221325, upload-time = "2026-06-20T14:49:01.447Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/37/8d7955f7e701e69198bd0a0132ea76518c078a635b930a4924e2ccfa70f0/coverage-7.14.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:58055adffabfa243516a197aa9f85f0dd56d905b0fba1a10193269759c29ccb0", size = 221594, upload-time = "2026-06-20T14:49:03.13Z" },
+ { url = "https://files.pythonhosted.org/packages/34/7a/6738e1e1533ce8ec4e2e472696eefdd4723864d7efaa140e433053bf576a/coverage-7.14.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:535747dbc200349d7fb434cffcb28e770f0290f69b225f56dc3803aa7210cdea", size = 262957, upload-time = "2026-06-20T14:49:04.829Z" },
+ { url = "https://files.pythonhosted.org/packages/35/c4/d1be863cd39e0955904315fece67c5c23e046563f5eea0ceac16c547a759/coverage-7.14.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:420c66e35d85c0ca5dc6a38147d83ef239762542900e5921ebbdb89333c540ea", size = 265081, upload-time = "2026-06-20T14:49:07.018Z" },
+ { url = "https://files.pythonhosted.org/packages/72/7f/412df3c3c251284a11834287fd6f7e3bb98c528c53e030589e9344a3ef80/coverage-7.14.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2cf17b33773be446a588551ea6a746b2d70dd0bc90dc31f1dd7648975a63c6b", size = 267500, upload-time = "2026-06-20T14:49:08.709Z" },
+ { url = "https://files.pythonhosted.org/packages/54/68/7d0764e83459455384d5c04179ce2d2a837bef01b9ba463079c6e8b31361/coverage-7.14.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adb4a5fef041f7179bb264203add873c147d169cf2f8d0adae89ff2e51271bac", size = 268619, upload-time = "2026-06-20T14:49:10.405Z" },
+ { url = "https://files.pythonhosted.org/packages/14/68/1292164ac70cbcc86ac3982da31a6fbb42bb4bcebf6e5cf73c99cfcfd50d/coverage-7.14.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c012ec357dec9408a83dad5541172a63c5cfa1421709f2e5811480d31ae1b28", size = 262066, upload-time = "2026-06-20T14:49:12.257Z" },
+ { url = "https://files.pythonhosted.org/packages/20/44/fd6fdf3f63b6e00a1a9230022d072ded5189576001685706aa6524187c65/coverage-7.14.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dacd0ecd08fda3cb2f85b60cabea7da326dcb2fc15fbb23a88830a80144cc9f2", size = 264953, upload-time = "2026-06-20T14:49:14.13Z" },
+ { url = "https://files.pythonhosted.org/packages/39/29/e803fea3da89eaeb5b6b41b3ccd039fe9f3300a900e3803baac1a998529f/coverage-7.14.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f27e980f2feba5dfe7a32b22b125470de69c0bd113c75e16165de909a777f512", size = 262555, upload-time = "2026-06-20T14:49:15.803Z" },
+ { url = "https://files.pythonhosted.org/packages/32/3c/b360e48ac68e3236c04cb83658382e7f5be7efbbec2e1faae3dcca432783/coverage-7.14.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:105c00efb65c863630b2b63cbf7b8267e4da2d44b62284efbb19a03b04c337d4", size = 266289, upload-time = "2026-06-20T14:49:17.962Z" },
+ { url = "https://files.pythonhosted.org/packages/59/12/1ed6d9274d599c586e2d1aa9818765dcdae6bb52aa88afa2fcd868398191/coverage-7.14.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:571173fa04c8e8d6235ab32ae67fecca97777e2e1b4a1a30f3022c34e397c1c1", size = 261402, upload-time = "2026-06-20T14:49:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/44/17/eb6cf12a4538cda937aefbeabb15377a8a30b377b484e63d31c9da790966/coverage-7.14.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e532f34d42d1a421fa00ed6b7735d14ac2e340256c1bad26a5e1dc1252b0bed7", size = 263715, upload-time = "2026-06-20T14:49:21.427Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/ca/4bafdb9d372ab05d6ed3a63e7f00d3195d169d0afea00f617c026e386c19/coverage-7.14.2-cp314-cp314t-win32.whl", hash = "sha256:243971550fb46c3039257f75e65610002d84304c505f609bbd9779e20a653a0a", size = 223103, upload-time = "2026-06-20T14:49:23.24Z" },
+ { url = "https://files.pythonhosted.org/packages/35/cb/0765dbd9011d2e47315f1da31e62c5fe231f04a6ec8da213e64c4505896d/coverage-7.14.2-cp314-cp314t-win_amd64.whl", hash = "sha256:60fb0ca084a92da96474b8b405a7ea76dfecac3c68db54383e7934b6f3871169", size = 223934, upload-time = "2026-06-20T14:49:25.347Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/ce/373dde027ecd0ae54511430fe7569f838d3a0376b70333ba9fd20c76b836/coverage-7.14.2-cp314-cp314t-win_arm64.whl", hash = "sha256:36a0a3f42ed7dfdbca2a69a541519ffd5064a5692152fc0018109e74370d7345", size = 223249, upload-time = "2026-06-20T14:49:27.241Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5e/a8ba14ceb014f39bd5e3f7077150718c7de61c01ce326bfe7e8eae9b19b2/coverage-7.14.2-py3-none-any.whl", hash = "sha256:04d92589e481a8b68a005a5a1e0646a91c76f322c397c4635298c57cf63699b5", size = 212325, upload-time = "2026-06-20T14:49:28.991Z" },
+]
+
[[package]]
name = "fastcore"
version = "1.13.6"
@@ -338,6 +436,99 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
+[[package]]
+name = "librt"
+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/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" },
+ { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
+ { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
+ { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
+ { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
+ { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
+ { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
+ { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
+ { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
+ { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
+ { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
+ { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ast-serialize" },
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+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/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" },
+ { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
+ { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
+ { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
+ { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
+ { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
+ { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
[[package]]
name = "oauthlib"
version = "3.3.1"
@@ -356,6 +547,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
[[package]]
name = "pip"
version = "26.1.2"
@@ -399,6 +599,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -486,6 +700,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
+[[package]]
+name = "ruff"
+version = "0.15.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" },
+ { url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" },
+ { url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" },
+ { url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" },
+ { url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" },
+ { url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" },
+ { url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" },
+ { url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" },
+]
+
[[package]]
name = "six"
version = "1.17.0"
@@ -557,7 +796,10 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx" },
+ { name = "mypy" },
{ name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "ruff" },
]
[package.metadata]
@@ -571,7 +813,10 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx", specifier = ">=0.27" },
+ { name = "mypy", specifier = ">=1.11" },
{ name = "pytest", specifier = ">=8.0" },
+ { name = "pytest-cov", specifier = ">=5.0" },
+ { name = "ruff", specifier = ">=0.6" },
]
[[package]]