diff --git a/backend/.env.example b/backend/.env.example index 0905d730..6ecce595 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,7 +6,8 @@ CORS_ORIGINS=http://localhost:3000 BASE_URL= # Clerk (auth only) -CLERK_JWKS_URL= +CLERK_SECRET_KEY=sk_test_your_clerk_secret_key +CLERK_API_URL=https://api.clerk.com CLERK_VERIFY_IAT=true CLERK_LEEWAY=10.0 diff --git a/backend/README.md b/backend/README.md index 555b1564..b4059b5a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -71,7 +71,9 @@ A starter file exists at `backend/.env.example`. Clerk is used for user authentication (optional for local/self-host in many setups). -- `CLERK_JWKS_URL` (string) +- `CLERK_SECRET_KEY` (required) + - Used to fetch user profile fields (email/name) from Clerk when JWT claims are minimal. +- `CLERK_API_URL` (default: `https://api.clerk.com`) - `CLERK_VERIFY_IAT` (default: `true`) - `CLERK_LEEWAY` (default: `10.0`) diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index badf311b..a797d4c1 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -4,6 +4,7 @@ from __future__ import annotations import re from typing import TYPE_CHECKING, Any +from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import SQLModel, col, select @@ -64,7 +65,6 @@ from app.services.task_dependencies import ( if TYPE_CHECKING: from collections.abc import Sequence - from uuid import UUID from fastapi_pagination.limit_offset import LimitOffsetPage from sqlmodel.ext.asyncio.session import AsyncSession diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 7466d7eb..2bb22e83 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -2,14 +2,17 @@ from __future__ import annotations +import logging from dataclasses import dataclass -from functools import lru_cache +from time import monotonic from typing import TYPE_CHECKING, Literal +import jwt +from clerk_backend_api import Clerk +from clerk_backend_api.models.clerkerrors import ClerkErrors +from clerk_backend_api.models.sdkerror import SDKError from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from fastapi_clerk_auth import ClerkConfig, ClerkHTTPBearer -from fastapi_clerk_auth import HTTPAuthorizationCredentials as ClerkCredentials from pydantic import BaseModel, ValidationError from app.core.config import settings @@ -18,12 +21,16 @@ from app.db.session import get_session from app.models.users import User if TYPE_CHECKING: + from clerk_backend_api.models.user import User as ClerkUser from sqlmodel.ext.asyncio.session import AsyncSession +logger = logging.getLogger(__name__) security = HTTPBearer(auto_error=False) SECURITY_DEP = Depends(security) SESSION_DEP = Depends(get_session) -CLERK_JWKS_URL_REQUIRED_ERROR = "CLERK_JWKS_URL is not set." +_JWKS_CACHE_TTL_SECONDS = 300.0 +_jwks_cache_payload: dict[str, object] | None = None +_jwks_cache_at_monotonic = 0.0 class ClerkTokenPayload(BaseModel): @@ -32,19 +39,6 @@ class ClerkTokenPayload(BaseModel): sub: str -@lru_cache -def _build_clerk_http_bearer(*, auto_error: bool) -> ClerkHTTPBearer: - """Create and cache the Clerk HTTP bearer guard.""" - if not settings.clerk_jwks_url: - raise RuntimeError(CLERK_JWKS_URL_REQUIRED_ERROR) - clerk_config = ClerkConfig( - jwks_url=settings.clerk_jwks_url, - verify_iat=settings.clerk_verify_iat, - leeway=settings.clerk_leeway, - ) - return ClerkHTTPBearer(config=clerk_config, auto_error=auto_error, add_state=True) - - @dataclass class AuthContext: """Authenticated user context resolved from inbound auth headers.""" @@ -53,25 +47,349 @@ class AuthContext: user: User | None = None -def _resolve_clerk_auth( - request: Request, - fallback: ClerkCredentials | None, -) -> ClerkCredentials | None: - auth_data = getattr(request.state, "clerk_auth", None) - if isinstance(auth_data, ClerkCredentials): - return auth_data - return fallback - - -def _parse_subject(auth_data: ClerkCredentials | None) -> str | None: - if not auth_data or not auth_data.decoded: +def _non_empty_str(value: object) -> str | None: + if not isinstance(value, str): return None - payload = ClerkTokenPayload.model_validate(auth_data.decoded) + cleaned = value.strip() + return cleaned or None + + +def _normalize_email(value: object) -> str | None: + text = _non_empty_str(value) + if text is None: + return None + return text.lower() + + +def _extract_claim_email(claims: dict[str, object]) -> str | None: + for key in ("email", "email_address", "primary_email_address"): + email = _normalize_email(claims.get(key)) + if email: + return email + + primary_email_id = _non_empty_str(claims.get("primary_email_address_id")) + email_addresses = claims.get("email_addresses") + if not isinstance(email_addresses, list): + return None + + fallback_email: str | None = None + for item in email_addresses: + if isinstance(item, str): + normalized = _normalize_email(item) + if normalized and fallback_email is None: + fallback_email = normalized + continue + if not isinstance(item, dict): + continue + candidate = _normalize_email(item.get("email_address") or item.get("email")) + if not candidate: + continue + candidate_id = _non_empty_str(item.get("id")) + if primary_email_id and candidate_id == primary_email_id: + return candidate + if fallback_email is None: + fallback_email = candidate + return fallback_email + + +def _extract_claim_name(claims: dict[str, object]) -> str | None: + for key in ("name", "full_name"): + text = _non_empty_str(claims.get(key)) + if text: + return text + + first = _non_empty_str(claims.get("given_name")) or _non_empty_str(claims.get("first_name")) + last = _non_empty_str(claims.get("family_name")) or _non_empty_str(claims.get("last_name")) + parts = [part for part in (first, last) if part] + if not parts: + return None + return " ".join(parts) + + +def _claim_debug_snapshot(claims: dict[str, object]) -> dict[str, object]: + email_addresses = claims.get("email_addresses") + email_samples: list[dict[str, str | None]] = [] + if isinstance(email_addresses, list): + for item in email_addresses[:5]: + if isinstance(item, dict): + email_samples.append( + { + "id": _non_empty_str(item.get("id")), + "email": _normalize_email( + item.get("email_address") or item.get("email"), + ), + }, + ) + elif isinstance(item, str): + email_samples.append({"id": None, "email": _normalize_email(item)}) + + return { + "keys": sorted(claims.keys()), + "iss": _non_empty_str(claims.get("iss")), + "sub": _non_empty_str(claims.get("sub")), + "email": _normalize_email(claims.get("email")), + "email_address": _normalize_email(claims.get("email_address")), + "primary_email_address": _normalize_email(claims.get("primary_email_address")), + "primary_email_address_id": _non_empty_str(claims.get("primary_email_address_id")), + "email_addresses_count": len(email_addresses) if isinstance(email_addresses, list) else 0, + "email_addresses_sample": email_samples, + "name": _non_empty_str(claims.get("name")), + "full_name": _non_empty_str(claims.get("full_name")), + "given_name": _non_empty_str(claims.get("given_name")) + or _non_empty_str(claims.get("first_name")), + "family_name": _non_empty_str(claims.get("family_name")) + or _non_empty_str(claims.get("last_name")), + } + + +def _extract_clerk_profile(profile: ClerkUser | None) -> tuple[str | None, str | None]: + if profile is None: + return None, None + + profile_email = _normalize_email(getattr(profile, "email_address", None)) + primary_email_id = _non_empty_str(getattr(profile, "primary_email_address_id", None)) + emails = getattr(profile, "email_addresses", None) + if not profile_email and isinstance(emails, list): + fallback_email: str | None = None + for item in emails: + candidate = _normalize_email( + getattr(item, "email_address", None), + ) + if not candidate: + continue + candidate_id = _non_empty_str(getattr(item, "id", None)) + if primary_email_id and candidate_id == primary_email_id: + profile_email = candidate + break + if fallback_email is None: + fallback_email = candidate + if profile_email is None: + profile_email = fallback_email + + profile_name = ( + _non_empty_str(getattr(profile, "full_name", None)) + or _non_empty_str(getattr(profile, "name", None)) + or _non_empty_str(getattr(profile, "first_name", None)) + or _non_empty_str(getattr(profile, "username", None)) + ) + if not profile_name: + first = _non_empty_str(getattr(profile, "first_name", None)) + last = _non_empty_str(getattr(profile, "last_name", None)) + parts = [part for part in (first, last) if part] + if parts: + profile_name = " ".join(parts) + + return profile_email, profile_name + + +def _normalize_clerk_server_url(raw: str) -> str | None: + server_url = raw.strip().rstrip("/") + if not server_url: + return None + if not server_url.endswith("/v1"): + server_url = f"{server_url}/v1" + return server_url + + +async def _fetch_clerk_jwks(*, force_refresh: bool = False) -> dict[str, object]: + global _jwks_cache_payload + global _jwks_cache_at_monotonic + + if ( + not force_refresh + and _jwks_cache_payload is not None + and monotonic() - _jwks_cache_at_monotonic < _JWKS_CACHE_TTL_SECONDS + ): + return _jwks_cache_payload + + secret = settings.clerk_secret_key.strip() + server_url = _normalize_clerk_server_url(settings.clerk_api_url or "") + async with Clerk( + bearer_auth=secret, + server_url=server_url, + timeout_ms=5000, + ) as clerk: + jwks = await clerk.jwks.get_async() + if jwks is None: + raise RuntimeError("Clerk JWKS response was empty.") + payload = jwks.model_dump() + if not isinstance(payload, dict): + raise RuntimeError("Clerk JWKS response had invalid shape.") + _jwks_cache_payload = payload + _jwks_cache_at_monotonic = monotonic() + return payload + + +def _public_key_for_kid(jwks_payload: dict[str, object], kid: str) -> jwt.PyJWK | None: + try: + jwk_set = jwt.PyJWKSet.from_dict(jwks_payload) + except jwt.PyJWTError: + return None + for key in jwk_set.keys: + if key.key_id == kid: + return key + return None + + +async def _decode_clerk_token(token: str) -> dict[str, object]: + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc + + kid = _non_empty_str(header.get("kid")) + if kid is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + secret_kind = settings.clerk_secret_key.strip().split("_", maxsplit=1)[0] + for attempt in (False, True): + try: + jwks_payload = await _fetch_clerk_jwks(force_refresh=attempt) + except (ClerkErrors, SDKError, RuntimeError): + logger.warning( + "auth.clerk.jwks.fetch_failed attempt=%s secret_kind=%s", + 2 if attempt else 1, + secret_kind, + exc_info=True, + ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from None + + key = _public_key_for_kid(jwks_payload, kid) + if key is None: + if attempt: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + continue + try: + decoded = jwt.decode( + token, + key=key, + algorithms=["RS256"], + options={ + "verify_aud": False, + "verify_iat": settings.clerk_verify_iat, + }, + leeway=settings.clerk_leeway, + ) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc + if not isinstance(decoded, dict): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return {str(k): v for k, v in decoded.items()} + + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + +async def _fetch_clerk_profile(clerk_user_id: str) -> tuple[str | None, str | None]: + secret = settings.clerk_secret_key.strip() + secret_kind = secret.split("_", maxsplit=1)[0] if "_" in secret else "unknown" + server_url = _normalize_clerk_server_url(settings.clerk_api_url or "") + + try: + async with Clerk( + bearer_auth=secret, + server_url=server_url, + timeout_ms=5000, + ) as clerk: + profile = await clerk.users.get_async(user_id=clerk_user_id) + email, name = _extract_clerk_profile(profile) + logger.info( + "auth.clerk.profile.fetch clerk_user_id=%s email=%s name=%s", + clerk_user_id, + email, + name, + ) + return email, name + except ClerkErrors as exc: + errors_payload = str(exc) + if len(errors_payload) > 300: + errors_payload = f"{errors_payload[:300]}..." + logger.warning( + "auth.clerk.profile.fetch_failed clerk_user_id=%s reason=clerk_errors " + "secret_kind=%s body=%s", + clerk_user_id, + secret_kind, + errors_payload, + ) + except SDKError as exc: + response_body = exc.body.strip() or None + if response_body and len(response_body) > 300: + response_body = f"{response_body[:300]}..." + logger.warning( + "auth.clerk.profile.fetch_failed clerk_user_id=%s status=%s reason=sdk_error " + "server_url=%s secret_kind=%s body=%s", + clerk_user_id, + exc.status_code, + server_url, + secret_kind, + response_body, + ) + except Exception: + logger.warning( + "auth.clerk.profile.fetch_failed clerk_user_id=%s reason=sdk_exception", + clerk_user_id, + exc_info=True, + ) + return None, None + + +async def _get_or_sync_user( + session: AsyncSession, + *, + clerk_user_id: str, + claims: dict[str, object], +) -> User: + email, name = await _fetch_clerk_profile(clerk_user_id) + logger.info( + "auth.claims.parsed clerk_user_id=%s extracted_email=%s extracted_name=%s claims=%s", + clerk_user_id, + email, + name, + _claim_debug_snapshot(claims), + ) + defaults: dict[str, object | None] = { + "email": email, + "name": name, + } + user, _created = await crud.get_or_create( + session, + User, + clerk_user_id=clerk_user_id, + defaults=defaults, + ) + + changed = False + if email and user.email != email: + user.email = email + changed = True + if not user.name and name: + user.name = name + changed = True + if changed: + session.add(user) + await session.commit() + await session.refresh(user) + logger.info( + "auth.user.sync clerk_user_id=%s updated=%s claim_email=%s final_email=%s", + clerk_user_id, + changed, + _normalize_email(defaults.get("email")), + _normalize_email(user.email), + ) + if not user.email: + logger.warning( + "auth.user.sync.missing_email clerk_user_id=%s claims=%s", + clerk_user_id, + _claim_debug_snapshot(claims), + ) + return user + + +def _parse_subject(claims: dict[str, object]) -> str | None: + payload = ClerkTokenPayload.model_validate(claims) return payload.sub async def get_auth_context( - request: Request, credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP, session: AsyncSession = SESSION_DEP, ) -> AuthContext: @@ -79,37 +397,18 @@ async def get_auth_context( if credentials is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + claims = await _decode_clerk_token(credentials.credentials) try: - guard = _build_clerk_http_bearer(auto_error=False) - clerk_credentials = await guard(request) - except (RuntimeError, ValueError) as exc: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc - except HTTPException as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc - - auth_data = _resolve_clerk_auth(request, clerk_credentials) - try: - clerk_user_id = _parse_subject(auth_data) + clerk_user_id = _parse_subject(claims) except ValidationError as exc: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc if not clerk_user_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - - claims: dict[str, object] = {} - if auth_data and auth_data.decoded: - claims = auth_data.decoded - email_obj = claims.get("email") - name_obj = claims.get("name") - defaults: dict[str, object | None] = { - "email": email_obj if isinstance(email_obj, str) else None, - "name": name_obj if isinstance(name_obj, str) else None, - } - user, _created = await crud.get_or_create( + user = await _get_or_sync_user( session, - User, clerk_user_id=clerk_user_id, - defaults=defaults, + claims=claims, ) from app.services.organizations import ensure_member_for_user @@ -133,36 +432,21 @@ async def get_auth_context_optional( return None try: - guard = _build_clerk_http_bearer(auto_error=False) - clerk_credentials = await guard(request) - except (RuntimeError, ValueError) as exc: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc + claims = await _decode_clerk_token(credentials.credentials) except HTTPException: return None - auth_data = _resolve_clerk_auth(request, clerk_credentials) try: - clerk_user_id = _parse_subject(auth_data) + clerk_user_id = _parse_subject(claims) except ValidationError: return None if not clerk_user_id: return None - - claims: dict[str, object] = {} - if auth_data and auth_data.decoded: - claims = auth_data.decoded - email_obj = claims.get("email") - name_obj = claims.get("name") - defaults: dict[str, object | None] = { - "email": email_obj if isinstance(email_obj, str) else None, - "name": name_obj if isinstance(name_obj, str) else None, - } - user, _created = await crud.get_or_create( + user = await _get_or_sync_user( session, - User, clerk_user_id=clerk_user_id, - defaults=defaults, + claims=claims, ) from app.services.organizations import ensure_member_for_user diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8fc6d128..30dce45d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,7 @@ from __future__ import annotations from pathlib import Path from typing import Self -from pydantic import model_validator +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict BACKEND_ROOT = Path(__file__).resolve().parents[2] @@ -28,7 +28,8 @@ class Settings(BaseSettings): redis_url: str = "redis://localhost:6379/0" # Clerk auth (auth only; roles stored in DB) - clerk_jwks_url: str = "" + clerk_secret_key: str = Field(min_length=1) + clerk_api_url: str = "https://api.clerk.com" clerk_verify_iat: bool = True clerk_leeway: float = 10.0 @@ -52,6 +53,8 @@ class Settings(BaseSettings): @model_validator(mode="after") def _defaults(self) -> Self: + if not self.clerk_secret_key.strip(): + raise ValueError("CLERK_SECRET_KEY must be set and non-empty.") # In dev, default to applying Alembic migrations at startup to avoid # schema drift (e.g. missing newly-added columns). if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev": diff --git a/backend/app/models/organizations.py b/backend/app/models/organizations.py index 44ddfc5f..408010c2 100644 --- a/backend/app/models/organizations.py +++ b/backend/app/models/organizations.py @@ -5,7 +5,6 @@ from __future__ import annotations from datetime import datetime from uuid import UUID, uuid4 -from sqlalchemy import UniqueConstraint from sqlmodel import Field from app.core.time import utcnow @@ -18,7 +17,6 @@ class Organization(QueryModel, table=True): """Top-level organization tenant record.""" __tablename__ = "organizations" # pyright: ignore[reportAssignmentType] - __table_args__ = (UniqueConstraint("name", name="uq_organizations_name"),) id: UUID = Field(default_factory=uuid4, primary_key=True) name: str = Field(index=True) diff --git a/backend/app/services/organizations.py b/backend/app/services/organizations.py index 985d5faf..d5d1e255 100644 --- a/backend/app/services/organizations.py +++ b/backend/app/services/organizations.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Iterable from fastapi import HTTPException, status -from sqlalchemy import func, or_ +from sqlalchemy import or_ from sqlmodel import col, select from app.core.time import utcnow @@ -48,23 +48,6 @@ def is_org_admin(member: OrganizationMember) -> bool: return member.role in ADMIN_ROLES -async def get_default_org(session: AsyncSession) -> Organization | None: - """Return the default personal organization if it exists.""" - return await Organization.objects.filter_by(name=DEFAULT_ORG_NAME).first(session) - - -async def ensure_default_org(session: AsyncSession) -> Organization: - """Ensure and return the default personal organization.""" - org = await get_default_org(session) - if org is not None: - return org - org = Organization(name=DEFAULT_ORG_NAME, created_at=utcnow(), updated_at=utcnow()) - session.add(org) - await session.commit() - await session.refresh(org) - return org - - async def get_member( session: AsyncSession, *, @@ -216,31 +199,41 @@ async def ensure_member_for_user( if existing is not None: return existing + # Serialize first-time provisioning per user to avoid concurrent duplicate org/member creation. + await session.exec( + select(User.id) + .where(col(User.id) == user.id) + .with_for_update(), + ) + + existing_member = await get_first_membership(session, user.id) + if existing_member is not None: + if user.active_organization_id != existing_member.organization_id: + user.active_organization_id = existing_member.organization_id + session.add(user) + await session.commit() + return existing_member + if user.email: invite = await _find_pending_invite(session, user.email) if invite is not None: return await accept_invite(session, invite, user) - org = await ensure_default_org(session) now = utcnow() - member_count = ( - await session.exec( - select(func.count()).where( - col(OrganizationMember.organization_id) == org.id, - ), - ) - ).one() - is_first = int(member_count or 0) == 0 + org = Organization(name=DEFAULT_ORG_NAME, created_at=now, updated_at=now) + session.add(org) + await session.flush() + org_id = org.id member = OrganizationMember( - organization_id=org.id, + organization_id=org_id, user_id=user.id, - role="owner" if is_first else "member", - all_boards_read=is_first, - all_boards_write=is_first, + role="owner", + all_boards_read=True, + all_boards_write=True, created_at=now, updated_at=now, ) - user.active_organization_id = org.id + user.active_organization_id = org_id session.add(user) session.add(member) await session.commit() diff --git a/backend/migrations/versions/a1e6b0d62f0c_drop_org_name_unique.py b/backend/migrations/versions/a1e6b0d62f0c_drop_org_name_unique.py new file mode 100644 index 00000000..25f618cc --- /dev/null +++ b/backend/migrations/versions/a1e6b0d62f0c_drop_org_name_unique.py @@ -0,0 +1,31 @@ +"""Allow duplicate organization names. + +Revision ID: a1e6b0d62f0c +Revises: 658dca8f4a11 +Create Date: 2026-02-09 00:00:00.000000 + +""" + +from __future__ import annotations + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1e6b0d62f0c" +down_revision = "658dca8f4a11" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Drop global unique constraint on organization names.""" + op.drop_constraint("uq_organizations_name", "organizations", type_="unique") + + +def downgrade() -> None: + """Restore global unique constraint on organization names.""" + op.create_unique_constraint( + "uq_organizations_name", + "organizations", + ["name"], + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index f50563de..4d5266fe 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -23,10 +23,10 @@ dependencies = [ "websockets==12.0", "rq==1.16.2", "redis==5.1.1", - "fastapi-clerk-auth==0.0.9", "sse-starlette==2.1.3", "jinja2==3.1.6", "fastapi-pagination==0.15.9", + "clerk-backend-api==1.4.1", ] [project.optional-dependencies] diff --git a/backend/tests/test_auth_claims.py b/backend/tests/test_auth_claims.py new file mode 100644 index 00000000..71b24389 --- /dev/null +++ b/backend/tests/test_auth_claims.py @@ -0,0 +1,165 @@ +# ruff: noqa: SLF001 + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any + +import pytest + +from app.core import auth +from app.models.users import User + + +@dataclass +class _FakeSession: + added: list[Any] = field(default_factory=list) + committed: int = 0 + refreshed: list[Any] = field(default_factory=list) + + def add(self, value: Any) -> None: + self.added.append(value) + + async def commit(self) -> None: + self.committed += 1 + + async def refresh(self, value: Any) -> None: + self.refreshed.append(value) + + +def test_extract_claim_email_prefers_direct_email() -> None: + claims: dict[str, object] = { + "email": " User@Example.com ", + "primary_email_address": "ignored@example.com", + } + assert auth._extract_claim_email(claims) == "user@example.com" + + +def test_extract_claim_email_from_primary_id() -> None: + claims: dict[str, object] = { + "primary_email_address_id": "id-2", + "email_addresses": [ + {"id": "id-1", "email_address": "first@example.com"}, + {"id": "id-2", "email_address": "chosen@example.com"}, + ], + } + assert auth._extract_claim_email(claims) == "chosen@example.com" + + +def test_extract_claim_email_falls_back_to_first_address() -> None: + claims: dict[str, object] = { + "email_addresses": [ + {"id": "id-1", "email_address": "first@example.com"}, + {"id": "id-2", "email_address": "second@example.com"}, + ], + } + assert auth._extract_claim_email(claims) == "first@example.com" + + +def test_extract_claim_name_from_parts() -> None: + claims: dict[str, object] = { + "given_name": "Alex", + "family_name": "Morgan", + } + assert auth._extract_claim_name(claims) == "Alex Morgan" + + +def test_extract_clerk_profile_prefers_primary_email() -> None: + profile = SimpleNamespace( + primary_email_address_id="e2", + email_addresses=[ + SimpleNamespace(id="e1", email_address="first@example.com"), + SimpleNamespace(id="e2", email_address="primary@example.com"), + ], + first_name="Asha", + last_name="Rao", + ) + email, name = auth._extract_clerk_profile(profile) + assert email == "primary@example.com" + assert name == "Asha" + + +@pytest.mark.asyncio +async def test_get_or_sync_user_updates_email_and_name(monkeypatch: pytest.MonkeyPatch) -> None: + existing = User(clerk_user_id="user_123", email="old@example.com", name=None) + + async def _fake_get_or_create(*_args: Any, **_kwargs: Any) -> tuple[User, bool]: + return existing, False + + async def _fake_fetch(_clerk_user_id: str) -> tuple[str | None, str | None]: + return "new@example.com", "New Name" + + monkeypatch.setattr(auth.crud, "get_or_create", _fake_get_or_create) + monkeypatch.setattr(auth, "_fetch_clerk_profile", _fake_fetch) + + session = _FakeSession() + out = await auth._get_or_sync_user( + session, # type: ignore[arg-type] + clerk_user_id="user_123", + claims={}, + ) + + assert out is existing + assert existing.email == "new@example.com" + assert existing.name == "New Name" + assert session.committed == 1 + assert session.refreshed == [existing] + + +@pytest.mark.asyncio +async def test_get_or_sync_user_uses_clerk_profile_when_claims_are_minimal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = User(clerk_user_id="user_123", email=None, name=None) + + async def _fake_get_or_create(*_args: Any, **_kwargs: Any) -> tuple[User, bool]: + return existing, False + + async def _fake_fetch(_clerk_user_id: str) -> tuple[str | None, str | None]: + return "from-clerk@example.com", "From Clerk" + + monkeypatch.setattr(auth.crud, "get_or_create", _fake_get_or_create) + monkeypatch.setattr(auth, "_fetch_clerk_profile", _fake_fetch) + + session = _FakeSession() + out = await auth._get_or_sync_user( + session, # type: ignore[arg-type] + clerk_user_id="user_123", + claims={"sub": "user_123"}, + ) + + assert out is existing + assert existing.email == "from-clerk@example.com" + assert existing.name == "From Clerk" + assert session.committed == 1 + assert session.refreshed == [existing] + + +@pytest.mark.asyncio +async def test_get_or_sync_user_skips_commit_when_unchanged( + monkeypatch: pytest.MonkeyPatch, +) -> None: + existing = User(clerk_user_id="user_123", email="same@example.com", name="Name") + + async def _fake_get_or_create(*_args: Any, **_kwargs: Any) -> tuple[User, bool]: + return existing, False + + async def _fake_fetch(_clerk_user_id: str) -> tuple[str | None, str | None]: + return "same@example.com", "Different Name" + + monkeypatch.setattr(auth.crud, "get_or_create", _fake_get_or_create) + monkeypatch.setattr(auth, "_fetch_clerk_profile", _fake_fetch) + + session = _FakeSession() + out = await auth._get_or_sync_user( + session, # type: ignore[arg-type] + clerk_user_id="user_123", + claims={}, + ) + + assert out is existing + assert existing.email == "same@example.com" + assert existing.name == "Name" + assert session.committed == 0 + assert session.refreshed == [] diff --git a/backend/tests/test_organizations_service.py b/backend/tests/test_organizations_service.py index 14d8ac06..41304c0a 100644 --- a/backend/tests/test_organizations_service.py +++ b/backend/tests/test_organizations_service.py @@ -43,12 +43,14 @@ class _FakeExecResult: class _FakeSession: exec_results: list[Any] get_results: dict[tuple[type[Any], Any], Any] = field(default_factory=dict) + commit_side_effects: list[Exception] = field(default_factory=list) added: list[Any] = field(default_factory=list) added_all: list[list[Any]] = field(default_factory=list) executed: list[Any] = field(default_factory=list) committed: int = 0 + rolled_back: int = 0 flushed: int = 0 refreshed: list[Any] = field(default_factory=list) @@ -71,8 +73,14 @@ class _FakeSession: self.added_all.append(values) async def commit(self) -> None: + if self.commit_side_effects: + effect = self.commit_side_effects.pop(0) + raise effect self.committed += 1 + async def rollback(self) -> None: + self.rolled_back += 1 + async def flush(self) -> None: self.flushed += 1 @@ -132,7 +140,7 @@ async def test_ensure_member_for_user_returns_existing_membership( monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active) - session = _FakeSession(exec_results=[]) + session = _FakeSession(exec_results=[_FakeExecResult()]) out = await organizations.ensure_member_for_user(session, user) assert out is existing @@ -156,6 +164,9 @@ async def test_ensure_member_for_user_accepts_pending_invite( async def _fake_find(_session: Any, _email: str) -> OrganizationInvite: return invite + async def _fake_get_first(_session: Any, _user_id: Any) -> None: + return None + accepted = OrganizationMember( organization_id=org_id, user_id=user.id, @@ -172,39 +183,73 @@ async def test_ensure_member_for_user_accepts_pending_invite( return accepted monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active) + monkeypatch.setattr(organizations, "get_first_membership", _fake_get_first) monkeypatch.setattr(organizations, "_find_pending_invite", _fake_find) monkeypatch.setattr(organizations, "accept_invite", _fake_accept) - session = _FakeSession(exec_results=[]) + session = _FakeSession(exec_results=[_FakeExecResult()]) out = await organizations.ensure_member_for_user(session, user) assert out is accepted @pytest.mark.asyncio -async def test_ensure_member_for_user_creates_default_org_and_first_owner( +async def test_ensure_member_for_user_creates_personal_org_and_owner( monkeypatch: pytest.MonkeyPatch, ) -> None: user = User(clerk_user_id="u1", email=None) - org = Organization(id=uuid4(), name=organizations.DEFAULT_ORG_NAME) async def _fake_get_active(_session: Any, _user: User) -> None: return None - async def _fake_ensure_default(_session: Any) -> Organization: - return org + async def _fake_get_first(_session: Any, _user_id: Any) -> None: + return None monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active) - monkeypatch.setattr(organizations, "ensure_default_org", _fake_ensure_default) + monkeypatch.setattr(organizations, "get_first_membership", _fake_get_first) - # member_count query returns 0 -> first member becomes owner - session = _FakeSession(exec_results=[_FakeExecResult(one_value=0)]) + session = _FakeSession(exec_results=[_FakeExecResult()]) out = await organizations.ensure_member_for_user(session, user) - assert out.organization_id == org.id assert out.user_id == user.id assert out.role == "owner" assert out.all_boards_read is True assert out.all_boards_write is True + assert out.organization_id == user.active_organization_id + assert any( + isinstance(item, Organization) and item.id == out.organization_id + for item in session.added + ) + assert session.committed == 1 + + +@pytest.mark.asyncio +async def test_ensure_member_for_user_reuses_existing_membership_after_lock( + monkeypatch: pytest.MonkeyPatch, +) -> None: + user = User(clerk_user_id="u1") + org = Organization(id=uuid4(), name=organizations.DEFAULT_ORG_NAME) + existing = OrganizationMember( + organization_id=org.id, + user_id=user.id, + role="member", + ) + + async def _fake_get_active(_session: Any, _user: User) -> None: + return None + + async def _fake_get_first( + _session: Any, + _user_id: Any, + ) -> OrganizationMember | None: + return existing + + monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active) + monkeypatch.setattr(organizations, "get_first_membership", _fake_get_first) + + session = _FakeSession(exec_results=[_FakeExecResult()]) + + out = await organizations.ensure_member_for_user(session, user) + assert out is existing assert user.active_organization_id == org.id assert session.committed == 1 diff --git a/backend/typings/fastapi_clerk_auth/__init__.pyi b/backend/typings/fastapi_clerk_auth/__init__.pyi deleted file mode 100644 index 88396859..00000000 --- a/backend/typings/fastapi_clerk_auth/__init__.pyi +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass - -from starlette.requests import Request - -@dataclass -class ClerkConfig: - jwks_url: str - verify_iat: bool = ... - leeway: float = ... - -class HTTPAuthorizationCredentials: - scheme: str - credentials: str - decoded: dict[str, object] | None - - def __init__( - self, - scheme: str, - credentials: str, - decoded: dict[str, object] | None = ..., - ) -> None: ... - -class ClerkHTTPBearer: - def __init__( - self, - config: ClerkConfig, - *, - auto_error: bool = ..., - add_state: bool = ..., - ) -> None: ... - async def __call__( - self, - request: Request, - ) -> HTTPAuthorizationCredentials | None: ... diff --git a/backend/uv.lock b/backend/uv.lock index aab4bb9c..7a17940f 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] [[package]] name = "aiosqlite" @@ -149,6 +153,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, ] +[[package]] +name = "clerk-backend-api" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "eval-type-backport" }, + { name = "httpx" }, + { name = "jsonpath-python" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "python-dateutil" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/f8/fe930ea9340a7ef6b0b3c4d1a1f89ff5e18f8123fdb7006b37c41ddd049b/clerk_backend_api-1.4.1.tar.gz", hash = "sha256:4b03f673307405247782b871cf3ed0e0bbc70bb4c1c005270e5e12e488eb2bbd", size = 134553, upload-time = "2024-12-05T16:06:58.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/e1/91683655816978445497344c8e43343814249625f4ac55fe25e8b708c401/clerk_backend_api-1.4.1-py3-none-any.whl", hash = "sha256:51b9dbf84a375aaf9a7c24f87c9ff61b898310e3016482438a23dd98d22e43b3", size = 272543, upload-time = "2024-12-05T16:06:56.303Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -210,37 +233,40 @@ wheels = [ [[package]] name = "cryptography" -version = "44.0.1" +version = "43.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819, upload-time = "2025-02-11T15:50:58.39Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989, upload-time = "2024-10-18T15:58:32.918Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/27/5e3524053b4c8889da65cf7814a9d0d8514a05194a25e1e34f46852ee6eb/cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", size = 6642022, upload-time = "2025-02-11T15:49:32.752Z" }, - { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865, upload-time = "2025-02-11T15:49:36.659Z" }, - { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562, upload-time = "2025-02-11T15:49:39.541Z" }, - { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923, upload-time = "2025-02-11T15:49:42.461Z" }, - { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194, upload-time = "2025-02-11T15:49:45.226Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790, upload-time = "2025-02-11T15:49:48.215Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343, upload-time = "2025-02-11T15:49:50.313Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127, upload-time = "2025-02-11T15:49:52.051Z" }, - { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666, upload-time = "2025-02-11T15:49:56.56Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811, upload-time = "2025-02-11T15:49:59.248Z" }, - { url = "https://files.pythonhosted.org/packages/38/78/74ea9eb547d13c34e984e07ec8a473eb55b19c1451fe7fc8077c6a4b0548/cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", size = 2771882, upload-time = "2025-02-11T15:50:01.478Z" }, - { url = "https://files.pythonhosted.org/packages/cf/6c/3907271ee485679e15c9f5e93eac6aa318f859b0aed8d369afd636fafa87/cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00", size = 3206989, upload-time = "2025-02-11T15:50:03.312Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f1/676e69c56a9be9fd1bffa9bc3492366901f6e1f8f4079428b05f1414e65c/cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", size = 6643714, upload-time = "2025-02-11T15:50:05.555Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269, upload-time = "2025-02-11T15:50:08.54Z" }, - { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461, upload-time = "2025-02-11T15:50:11.419Z" }, - { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314, upload-time = "2025-02-11T15:50:14.181Z" }, - { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675, upload-time = "2025-02-11T15:50:16.3Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429, upload-time = "2025-02-11T15:50:19.302Z" }, - { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039, upload-time = "2025-02-11T15:50:22.257Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713, upload-time = "2025-02-11T15:50:24.261Z" }, - { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193, upload-time = "2025-02-11T15:50:26.18Z" }, - { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566, upload-time = "2025-02-11T15:50:28.221Z" }, - { url = "https://files.pythonhosted.org/packages/30/6f/4eca9e2e0f13ae459acd1ca7d9f0257ab86e68f44304847610afcb813dc9/cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", size = 2772371, upload-time = "2025-02-11T15:50:29.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/05/5533d30f53f10239616a357f080892026db2d550a40c393d0a8a7af834a9/cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", size = 3207303, upload-time = "2025-02-11T15:50:32.258Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303, upload-time = "2024-10-18T15:57:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905, upload-time = "2024-10-18T15:57:39.166Z" }, + { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271, upload-time = "2024-10-18T15:57:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606, upload-time = "2024-10-18T15:57:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484, upload-time = "2024-10-18T15:57:45.434Z" }, + { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131, upload-time = "2024-10-18T15:57:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647, upload-time = "2024-10-18T15:57:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873, upload-time = "2024-10-18T15:57:51.822Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039, upload-time = "2024-10-18T15:57:54.426Z" }, + { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984, upload-time = "2024-10-18T15:57:56.174Z" }, + { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968, upload-time = "2024-10-18T15:57:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754, upload-time = "2024-10-18T15:58:00.683Z" }, + { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458, upload-time = "2024-10-18T15:58:02.225Z" }, + { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220, upload-time = "2024-10-18T15:58:04.331Z" }, + { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898, upload-time = "2024-10-18T15:58:06.113Z" }, + { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592, upload-time = "2024-10-18T15:58:08.673Z" }, + { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145, upload-time = "2024-10-18T15:58:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026, upload-time = "2024-10-18T15:58:11.916Z" }, +] + +[[package]] +name = "eval-type-backport" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/ea/8b0ac4469d4c347c6a385ff09dc3c048c2d021696664e26c7ee6791631b5/eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1", size = 9079, upload-time = "2024-12-21T20:09:46.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830, upload-time = "2024-12-21T20:09:44.175Z" }, ] [[package]] @@ -258,20 +284,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, ] -[[package]] -name = "fastapi-clerk-auth" -version = "0.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "fastapi" }, - { name = "pyjwt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/86/af/410786fc25e52378df227f4d04e6b43b8c3bc9007221440822e9a10af051/fastapi_clerk_auth-0.0.9.tar.gz", hash = "sha256:503bdd3943e5f49095bd5dd04a8b14ebe6214bb3f39ae45df876e6275db5cf6d", size = 14359, upload-time = "2025-11-11T06:12:37.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/4e/058ecbe4fa0d470c3979f1272c0199cc47afb0ed935edb07b55441be8994/fastapi_clerk_auth-0.0.9-py3-none-any.whl", hash = "sha256:f9a47cfc65a2562c144a798ce0022a288799dac1149001b5a109865d578b2647", size = 6464, upload-time = "2025-11-11T06:12:35.655Z" }, -] - [[package]] name = "fastapi-pagination" version = "0.15.9" @@ -449,6 +461,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonpath-python" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/bf/626a72f2d093c5eb4f4de55b443714afa7231beeae40d4a1c69b5c5aa4d1/jsonpath_python-1.1.4.tar.gz", hash = "sha256:bb3e13854e4807c078a1503ae2d87c211b8bff4d9b40b6455ed583b3b50a7fdd", size = 84766, upload-time = "2025-11-25T12:08:39.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/bc/52e5bf0d9839e082b976c19afcab7561d0d719c7627483bf5dc251d27eed/jsonpath_python-1.1.4-py3-none-any.whl", hash = "sha256:8700cb8610c44da6e5e9bff50232779c44bf7dc5bc62662d49319ee746898442", size = 12687, upload-time = "2025-11-25T12:08:38.453Z" }, +] + [[package]] name = "mako" version = "1.3.10" @@ -566,8 +587,8 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "clerk-backend-api" }, { name = "fastapi" }, - { name = "fastapi-clerk-auth" }, { name = "fastapi-pagination" }, { name = "jinja2" }, { name = "psycopg", extra = ["binary"] }, @@ -602,9 +623,9 @@ requires-dist = [ { name = "aiosqlite", marker = "extra == 'dev'", specifier = "==0.21.0" }, { name = "alembic", specifier = "==1.13.2" }, { name = "black", marker = "extra == 'dev'", specifier = "==24.10.0" }, + { name = "clerk-backend-api", specifier = "==1.4.1" }, { name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.6.10" }, { name = "fastapi", specifier = "==0.128.0" }, - { name = "fastapi-clerk-auth", specifier = "==0.0.9" }, { name = "fastapi-pagination", specifier = "==0.15.9" }, { name = "flake8", marker = "extra == 'dev'", specifier = "==7.1.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.27.0" }, @@ -742,88 +763,51 @@ wheels = [ [[package]] name = "pydantic" -version = "2.12.5" +version = "2.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, - { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917, upload-time = "2024-09-17T15:59:54.273Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928, upload-time = "2024-09-17T15:59:51.827Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.23.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156, upload-time = "2024-09-16T16:06:44.786Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/74/7b/8e315f80666194b354966ec84b7d567da77ad927ed6323db4006cf915f3f/pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", size = 1856459, upload-time = "2024-09-16T16:04:38.438Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/866bdce10ed808323d437612aca1ec9971b981e1c52e5e42ad9b8e17a6f6/pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", size = 1770007, upload-time = "2024-09-16T16:04:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/dc/69/8edd5c3cd48bb833a3f7ef9b81d7666ccddd3c9a635225214e044b6e8281/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", size = 1790245, upload-time = "2024-09-16T16:04:41.794Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/9c24334e3af796ce80d2274940aae38dd4e5676298b4398eff103a79e02d/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", size = 1801260, upload-time = "2024-09-16T16:04:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6f/e9567fd90104b79b101ca9d120219644d3314962caa7948dd8b965e9f83e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", size = 1996872, upload-time = "2024-09-16T16:04:45.593Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ad/b5f0fe9e6cfee915dd144edbd10b6e9c9c9c9d7a56b69256d124b8ac682e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", size = 2661617, upload-time = "2024-09-16T16:04:47.3Z" }, + { url = "https://files.pythonhosted.org/packages/06/c8/7d4b708f8d05a5cbfda3243aad468052c6e99de7d0937c9146c24d9f12e9/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", size = 2071831, upload-time = "2024-09-16T16:04:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/89/4d/3079d00c47f22c9a9a8220db088b309ad6e600a73d7a69473e3a8e5e3ea3/pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", size = 1917453, upload-time = "2024-09-16T16:04:51.099Z" }, + { url = "https://files.pythonhosted.org/packages/e9/88/9df5b7ce880a4703fcc2d76c8c2d8eb9f861f79d0c56f4b8f5f2607ccec8/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", size = 1968793, upload-time = "2024-09-16T16:04:52.604Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b9/41f7efe80f6ce2ed3ee3c2dcfe10ab7adc1172f778cc9659509a79518c43/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", size = 2116872, upload-time = "2024-09-16T16:04:54.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/08/b59b7a92e03dd25554b0436554bf23e7c29abae7cce4b1c459cd92746811/pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", size = 1738535, upload-time = "2024-09-16T16:04:55.828Z" }, + { url = "https://files.pythonhosted.org/packages/88/8d/479293e4d39ab409747926eec4329de5b7129beaedc3786eca070605d07f/pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", size = 1917992, upload-time = "2024-09-16T16:04:57.395Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143, upload-time = "2024-09-16T16:04:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063, upload-time = "2024-09-16T16:05:00.522Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013, upload-time = "2024-09-16T16:05:02.619Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077, upload-time = "2024-09-16T16:05:04.154Z" }, + { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782, upload-time = "2024-09-16T16:05:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375, upload-time = "2024-09-16T16:05:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635, upload-time = "2024-09-16T16:05:10.456Z" }, + { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994, upload-time = "2024-09-16T16:05:12.051Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877, upload-time = "2024-09-16T16:05:14.021Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814, upload-time = "2024-09-16T16:05:15.684Z" }, + { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360, upload-time = "2024-09-16T16:05:17.258Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411, upload-time = "2024-09-16T16:05:18.934Z" }, ] [[package]] @@ -897,6 +881,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -999,6 +995,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879, upload-time = "2024-10-04T13:40:25.797Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -1084,15 +1089,16 @@ wheels = [ ] [[package]] -name = "typing-inspection" -version = "0.4.2" +name = "typing-inspect" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, ] [[package]]