refactor: update Clerk authentication integration and improve organization handling
This commit is contained in:
@@ -6,7 +6,8 @@ CORS_ORIGINS=http://localhost:3000
|
|||||||
BASE_URL=
|
BASE_URL=
|
||||||
|
|
||||||
# Clerk (auth only)
|
# 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_VERIFY_IAT=true
|
||||||
CLERK_LEEWAY=10.0
|
CLERK_LEEWAY=10.0
|
||||||
|
|
||||||
|
|||||||
@@ -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 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_VERIFY_IAT` (default: `true`)
|
||||||
- `CLERK_LEEWAY` (default: `10.0`)
|
- `CLERK_LEEWAY` (default: `10.0`)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlmodel import SQLModel, col, select
|
from sqlmodel import SQLModel, col, select
|
||||||
@@ -64,7 +65,6 @@ from app.services.task_dependencies import (
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi_pagination.limit_offset import LimitOffsetPage
|
from fastapi_pagination.limit_offset import LimitOffsetPage
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import lru_cache
|
from time import monotonic
|
||||||
from typing import TYPE_CHECKING, Literal
|
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 import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
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 pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -18,12 +21,16 @@ from app.db.session import get_session
|
|||||||
from app.models.users import User
|
from app.models.users import User
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from clerk_backend_api.models.user import User as ClerkUser
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
SECURITY_DEP = Depends(security)
|
SECURITY_DEP = Depends(security)
|
||||||
SESSION_DEP = Depends(get_session)
|
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):
|
class ClerkTokenPayload(BaseModel):
|
||||||
@@ -32,19 +39,6 @@ class ClerkTokenPayload(BaseModel):
|
|||||||
sub: str
|
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
|
@dataclass
|
||||||
class AuthContext:
|
class AuthContext:
|
||||||
"""Authenticated user context resolved from inbound auth headers."""
|
"""Authenticated user context resolved from inbound auth headers."""
|
||||||
@@ -53,25 +47,349 @@ class AuthContext:
|
|||||||
user: User | None = None
|
user: User | None = None
|
||||||
|
|
||||||
|
|
||||||
def _resolve_clerk_auth(
|
def _non_empty_str(value: object) -> str | None:
|
||||||
request: Request,
|
if not isinstance(value, str):
|
||||||
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:
|
|
||||||
return None
|
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
|
return payload.sub
|
||||||
|
|
||||||
|
|
||||||
async def get_auth_context(
|
async def get_auth_context(
|
||||||
request: Request,
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP,
|
credentials: HTTPAuthorizationCredentials | None = SECURITY_DEP,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
) -> AuthContext:
|
) -> AuthContext:
|
||||||
@@ -79,37 +397,18 @@ async def get_auth_context(
|
|||||||
if credentials is None:
|
if credentials is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
claims = await _decode_clerk_token(credentials.credentials)
|
||||||
try:
|
try:
|
||||||
guard = _build_clerk_http_bearer(auto_error=False)
|
clerk_user_id = _parse_subject(claims)
|
||||||
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)
|
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
|
||||||
|
|
||||||
if not clerk_user_id:
|
if not clerk_user_id:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
user = await _get_or_sync_user(
|
||||||
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(
|
|
||||||
session,
|
session,
|
||||||
User,
|
|
||||||
clerk_user_id=clerk_user_id,
|
clerk_user_id=clerk_user_id,
|
||||||
defaults=defaults,
|
claims=claims,
|
||||||
)
|
)
|
||||||
from app.services.organizations import ensure_member_for_user
|
from app.services.organizations import ensure_member_for_user
|
||||||
|
|
||||||
@@ -133,36 +432,21 @@ async def get_auth_context_optional(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
guard = _build_clerk_http_bearer(auto_error=False)
|
claims = await _decode_clerk_token(credentials.credentials)
|
||||||
clerk_credentials = await guard(request)
|
|
||||||
except (RuntimeError, ValueError) as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
auth_data = _resolve_clerk_auth(request, clerk_credentials)
|
|
||||||
try:
|
try:
|
||||||
clerk_user_id = _parse_subject(auth_data)
|
clerk_user_id = _parse_subject(claims)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not clerk_user_id:
|
if not clerk_user_id:
|
||||||
return None
|
return None
|
||||||
|
user = await _get_or_sync_user(
|
||||||
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(
|
|
||||||
session,
|
session,
|
||||||
User,
|
|
||||||
clerk_user_id=clerk_user_id,
|
clerk_user_id=clerk_user_id,
|
||||||
defaults=defaults,
|
claims=claims,
|
||||||
)
|
)
|
||||||
from app.services.organizations import ensure_member_for_user
|
from app.services.organizations import ensure_member_for_user
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Self
|
from typing import Self
|
||||||
|
|
||||||
from pydantic import model_validator
|
from pydantic import Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
BACKEND_ROOT = Path(__file__).resolve().parents[2]
|
||||||
@@ -28,7 +28,8 @@ class Settings(BaseSettings):
|
|||||||
redis_url: str = "redis://localhost:6379/0"
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
# Clerk auth (auth only; roles stored in DB)
|
# 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_verify_iat: bool = True
|
||||||
clerk_leeway: float = 10.0
|
clerk_leeway: float = 10.0
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _defaults(self) -> Self:
|
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
|
# In dev, default to applying Alembic migrations at startup to avoid
|
||||||
# schema drift (e.g. missing newly-added columns).
|
# schema drift (e.g. missing newly-added columns).
|
||||||
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
|
if "db_auto_migrate" not in self.model_fields_set and self.environment == "dev":
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from sqlalchemy import UniqueConstraint
|
|
||||||
from sqlmodel import Field
|
from sqlmodel import Field
|
||||||
|
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
@@ -18,7 +17,6 @@ class Organization(QueryModel, table=True):
|
|||||||
"""Top-level organization tenant record."""
|
"""Top-level organization tenant record."""
|
||||||
|
|
||||||
__tablename__ = "organizations" # pyright: ignore[reportAssignmentType]
|
__tablename__ = "organizations" # pyright: ignore[reportAssignmentType]
|
||||||
__table_args__ = (UniqueConstraint("name", name="uq_organizations_name"),)
|
|
||||||
|
|
||||||
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
id: UUID = Field(default_factory=uuid4, primary_key=True)
|
||||||
name: str = Field(index=True)
|
name: str = Field(index=True)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||||||
from typing import TYPE_CHECKING, Iterable
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import or_
|
||||||
from sqlmodel import col, select
|
from sqlmodel import col, select
|
||||||
|
|
||||||
from app.core.time import utcnow
|
from app.core.time import utcnow
|
||||||
@@ -48,23 +48,6 @@ def is_org_admin(member: OrganizationMember) -> bool:
|
|||||||
return member.role in ADMIN_ROLES
|
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(
|
async def get_member(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -216,31 +199,41 @@ async def ensure_member_for_user(
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
return existing
|
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:
|
if user.email:
|
||||||
invite = await _find_pending_invite(session, user.email)
|
invite = await _find_pending_invite(session, user.email)
|
||||||
if invite is not None:
|
if invite is not None:
|
||||||
return await accept_invite(session, invite, user)
|
return await accept_invite(session, invite, user)
|
||||||
|
|
||||||
org = await ensure_default_org(session)
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
member_count = (
|
org = Organization(name=DEFAULT_ORG_NAME, created_at=now, updated_at=now)
|
||||||
await session.exec(
|
session.add(org)
|
||||||
select(func.count()).where(
|
await session.flush()
|
||||||
col(OrganizationMember.organization_id) == org.id,
|
org_id = org.id
|
||||||
),
|
|
||||||
)
|
|
||||||
).one()
|
|
||||||
is_first = int(member_count or 0) == 0
|
|
||||||
member = OrganizationMember(
|
member = OrganizationMember(
|
||||||
organization_id=org.id,
|
organization_id=org_id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
role="owner" if is_first else "member",
|
role="owner",
|
||||||
all_boards_read=is_first,
|
all_boards_read=True,
|
||||||
all_boards_write=is_first,
|
all_boards_write=True,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
)
|
)
|
||||||
user.active_organization_id = org.id
|
user.active_organization_id = org_id
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.add(member)
|
session.add(member)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
)
|
||||||
@@ -23,10 +23,10 @@ dependencies = [
|
|||||||
"websockets==12.0",
|
"websockets==12.0",
|
||||||
"rq==1.16.2",
|
"rq==1.16.2",
|
||||||
"redis==5.1.1",
|
"redis==5.1.1",
|
||||||
"fastapi-clerk-auth==0.0.9",
|
|
||||||
"sse-starlette==2.1.3",
|
"sse-starlette==2.1.3",
|
||||||
"jinja2==3.1.6",
|
"jinja2==3.1.6",
|
||||||
"fastapi-pagination==0.15.9",
|
"fastapi-pagination==0.15.9",
|
||||||
|
"clerk-backend-api==1.4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
165
backend/tests/test_auth_claims.py
Normal file
165
backend/tests/test_auth_claims.py
Normal file
@@ -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 == []
|
||||||
@@ -43,12 +43,14 @@ class _FakeExecResult:
|
|||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
exec_results: list[Any]
|
exec_results: list[Any]
|
||||||
get_results: dict[tuple[type[Any], Any], Any] = field(default_factory=dict)
|
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: list[Any] = field(default_factory=list)
|
||||||
added_all: list[list[Any]] = field(default_factory=list)
|
added_all: list[list[Any]] = field(default_factory=list)
|
||||||
executed: list[Any] = field(default_factory=list)
|
executed: list[Any] = field(default_factory=list)
|
||||||
|
|
||||||
committed: int = 0
|
committed: int = 0
|
||||||
|
rolled_back: int = 0
|
||||||
flushed: int = 0
|
flushed: int = 0
|
||||||
refreshed: list[Any] = field(default_factory=list)
|
refreshed: list[Any] = field(default_factory=list)
|
||||||
|
|
||||||
@@ -71,8 +73,14 @@ class _FakeSession:
|
|||||||
self.added_all.append(values)
|
self.added_all.append(values)
|
||||||
|
|
||||||
async def commit(self) -> None:
|
async def commit(self) -> None:
|
||||||
|
if self.commit_side_effects:
|
||||||
|
effect = self.commit_side_effects.pop(0)
|
||||||
|
raise effect
|
||||||
self.committed += 1
|
self.committed += 1
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
self.rolled_back += 1
|
||||||
|
|
||||||
async def flush(self) -> None:
|
async def flush(self) -> None:
|
||||||
self.flushed += 1
|
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)
|
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)
|
out = await organizations.ensure_member_for_user(session, user)
|
||||||
assert out is existing
|
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:
|
async def _fake_find(_session: Any, _email: str) -> OrganizationInvite:
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
|
async def _fake_get_first(_session: Any, _user_id: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
accepted = OrganizationMember(
|
accepted = OrganizationMember(
|
||||||
organization_id=org_id,
|
organization_id=org_id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -172,39 +183,73 @@ async def test_ensure_member_for_user_accepts_pending_invite(
|
|||||||
return accepted
|
return accepted
|
||||||
|
|
||||||
monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active)
|
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, "_find_pending_invite", _fake_find)
|
||||||
monkeypatch.setattr(organizations, "accept_invite", _fake_accept)
|
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)
|
out = await organizations.ensure_member_for_user(session, user)
|
||||||
assert out is accepted
|
assert out is accepted
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
user = User(clerk_user_id="u1", email=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:
|
async def _fake_get_active(_session: Any, _user: User) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _fake_ensure_default(_session: Any) -> Organization:
|
async def _fake_get_first(_session: Any, _user_id: Any) -> None:
|
||||||
return org
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr(organizations, "get_active_membership", _fake_get_active)
|
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()])
|
||||||
session = _FakeSession(exec_results=[_FakeExecResult(one_value=0)])
|
|
||||||
|
|
||||||
out = await organizations.ensure_member_for_user(session, user)
|
out = await organizations.ensure_member_for_user(session, user)
|
||||||
assert out.organization_id == org.id
|
|
||||||
assert out.user_id == user.id
|
assert out.user_id == user.id
|
||||||
assert out.role == "owner"
|
assert out.role == "owner"
|
||||||
assert out.all_boards_read is True
|
assert out.all_boards_read is True
|
||||||
assert out.all_boards_write 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 user.active_organization_id == org.id
|
||||||
assert session.committed == 1
|
assert session.committed == 1
|
||||||
|
|
||||||
|
|||||||
@@ -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: ...
|
|
||||||
230
backend/uv.lock
generated
230
backend/uv.lock
generated
@@ -1,6 +1,10 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.13'",
|
||||||
|
"python_full_version < '3.13'",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiosqlite"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.3.1"
|
||||||
@@ -210,37 +233,40 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "44.0.1"
|
version = "43.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||||
{ 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" },
|
[[package]]
|
||||||
{ 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" },
|
name = "eval-type-backport"
|
||||||
{ 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" },
|
version = "0.2.2"
|
||||||
{ 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" },
|
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]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "fastapi-pagination"
|
name = "fastapi-pagination"
|
||||||
version = "0.15.9"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "mako"
|
name = "mako"
|
||||||
version = "1.3.10"
|
version = "1.3.10"
|
||||||
@@ -566,8 +587,8 @@ version = "0.1.0"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
{ name = "clerk-backend-api" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastapi-clerk-auth" },
|
|
||||||
{ name = "fastapi-pagination" },
|
{ name = "fastapi-pagination" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
@@ -602,9 +623,9 @@ requires-dist = [
|
|||||||
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = "==0.21.0" },
|
{ name = "aiosqlite", marker = "extra == 'dev'", specifier = "==0.21.0" },
|
||||||
{ name = "alembic", specifier = "==1.13.2" },
|
{ name = "alembic", specifier = "==1.13.2" },
|
||||||
{ name = "black", marker = "extra == 'dev'", specifier = "==24.10.0" },
|
{ 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 = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.6.10" },
|
||||||
{ name = "fastapi", specifier = "==0.128.0" },
|
{ name = "fastapi", specifier = "==0.128.0" },
|
||||||
{ name = "fastapi-clerk-auth", specifier = "==0.0.9" },
|
|
||||||
{ name = "fastapi-pagination", specifier = "==0.15.9" },
|
{ name = "fastapi-pagination", specifier = "==0.15.9" },
|
||||||
{ name = "flake8", marker = "extra == 'dev'", specifier = "==7.1.1" },
|
{ name = "flake8", marker = "extra == 'dev'", specifier = "==7.1.1" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.27.0" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = "==0.27.0" },
|
||||||
@@ -742,88 +763,51 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.12.5"
|
version = "2.9.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-types" },
|
{ name = "annotated-types" },
|
||||||
{ name = "pydantic-core" },
|
{ name = "pydantic-core" },
|
||||||
{ name = "typing-extensions" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.41.5"
|
version = "2.23.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ 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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.0.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -1084,15 +1089,16 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-inspection"
|
name = "typing-inspect"
|
||||||
version = "0.4.2"
|
version = "0.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
{ name = "typing-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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user