From dda14f0f7b67d9a82a94249a3f02ebf659b3f1cc Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:49:20 -0300 Subject: [PATCH 01/28] feat: adds first auth capabilities this commit introduces supabase support, initially through supabase's cli, which should support normal postgresql activity + auth capabilities it also introduces the auth/ and access/ domains on the backend auth/ is used for validating issued JWT tokens and check if users are validated to do something. so, it just validates a token access/ is used to check if a given token has access to a session. can also be used to check if the user's role is chair/delegate, etc core/ defines initial config we'll use, such as supabase db url and jwt secrets do keep in mind all of this, currently, breaks frontend and backend integration (for the better) --- backend/app/access/models.py | 11 + backend/app/access/repository.py | 43 ++ backend/app/access/service.py | 26 ++ backend/app/access/tests/test_access.py | 1 + backend/app/auth/service.py | 42 ++ backend/app/core/config.py | 20 + supabase/.gitignore | 8 + supabase/README.md | 12 + supabase/config.toml | 414 ++++++++++++++++++ .../20260719224327_create_sessions_table.sql | 19 + 10 files changed, 596 insertions(+) create mode 100644 backend/app/access/models.py create mode 100644 backend/app/access/repository.py create mode 100644 backend/app/access/service.py create mode 100644 backend/app/access/tests/test_access.py create mode 100644 backend/app/auth/service.py create mode 100644 backend/app/core/config.py create mode 100644 supabase/.gitignore create mode 100644 supabase/README.md create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260719224327_create_sessions_table.sql diff --git a/backend/app/access/models.py b/backend/app/access/models.py new file mode 100644 index 0000000..c86c75f --- /dev/null +++ b/backend/app/access/models.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from typing import Literal +from uuid import UUID + +@dataclass(frozen=True) +class CommitteeAssignment: + """Object that holds info about an UUID to a commitee and Delegation / Chair """ + user_id: UUID + session_id: int # TODO: remove this to map out to committees/conferences + role: Literal["CHAIR", "DELEGATE"] + delegation_id: int | None diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py new file mode 100644 index 0000000..abdb7ee --- /dev/null +++ b/backend/app/access/repository.py @@ -0,0 +1,43 @@ +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from .models import CommitteeAssignment + +class AccessRepository: + def __init__(self, session: AsyncSession): + self.session = session + + async def get_committee_assignment(self, user_id: UUID, session_id: int) -> CommitteeAssignment | None: + query = text(""" + SELECT + sa.user_id, + sa.role, + sa.delegation_id + FROM public.sessions s + JOIN public.session_assignments sa + WHERE s.id = :session_id AND + sa.user_id = :user_id + """) + + result = await self.session.execute( + query, + { + "session_id": session_id, + "user_id": user_id + } + ) + + row = result.mappings().one_or_none() + if row is None: + return None + + return CommitteeAssignment( + user_id=row["user_id"], + session_id=session_id, + role=row["role"], + delegation_id=row["delegation_id"] + ) + + + diff --git a/backend/app/access/service.py b/backend/app/access/service.py new file mode 100644 index 0000000..96c4a17 --- /dev/null +++ b/backend/app/access/service.py @@ -0,0 +1,26 @@ +from uuid import UUID +from app.access.models import CommitteeAssignment +from app.access.repository import AccessRepository +from app.session.enums import SessionRole + +class AccessDenied(Exception): + ... + +class AccessService: + def __init__(self, repository: AccessRepository): + self.repository = repository + + async def resolve_committee_assignment( + self, + user_id: UUID, + session_id: int, + ) -> CommitteeAssignment: + assignment = await self.repository.get_committee_assignment(user_id, session_id) + + if assignment is None: + raise AccessDenied("User has no committee assignment") + + if assignment.role == SessionRole.DELEGATE and assignment.delegation_id is None: + raise AccessDenied("Delegate role has no delegation id") + + return assignment diff --git a/backend/app/access/tests/test_access.py b/backend/app/access/tests/test_access.py new file mode 100644 index 0000000..860673b --- /dev/null +++ b/backend/app/access/tests/test_access.py @@ -0,0 +1 @@ +import pytest diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py new file mode 100644 index 0000000..6fbf18d --- /dev/null +++ b/backend/app/auth/service.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from typing import Annotated +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from uuid import UUID +from pydantic import BaseModel +from app.core.config import Settings + + +class AuthUser(BaseModel): + user_id: UUID + email: str | None + +class InvalidTokenError(Exception): + pass + + +class AuthService: + def __init__(self, settings: Settings): + self.settings = settings + + def verify_user_token( + self, + token: str # self contained token that already has info like id, email, etc + ) -> AuthUser: + """Local Verification of user's JWT + Supabase JWT Secret""" + try: + payload = jwt.decode( + token, + self.settings.SUPABASE_JWT_SECRET.get_secret_value(), + self.settings.JWT_ALGORITHM, + audience="authenticated" + ) + + return AuthUser(user_id=UUID(payload.get("id")), email=payload.get("email")) + + except jwt.ExpiredSignatureError: + raise InvalidTokenError("Token has expired") + + except jwt.InvalidTokenError: + raise InvalidTokenError("Token is invalid") diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..95587b8 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,20 @@ +from functools import lru_cache +from pydantic import AnyHttpUrl, SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + +class Settings(BaseSettings): + # app name + APP_NAME: str = "Meu App FastAPI" + ENVIRONMENT: str = "development" + + # supabase config + # SUPABASE_URL: AnyHttpUrl + # SUPABASE_ANON_KEY: SecretStr + # SUPABASE_JWT_SECRET: SecretStr + # JWT_ALGORITHM + + # pydantic config to read .env files + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore" + ) diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/README.md b/supabase/README.md new file mode 100644 index 0000000..6040a98 --- /dev/null +++ b/supabase/README.md @@ -0,0 +1,12 @@ +# Supabase development + +Ensure you have Supabase CLI installed as a dependency. + +Workflow docs can be viewed (here)[https://supabase.com/docs/guides/local-development/database-migrations]. +You can opt for using the manual way or the GUI way. + +Supabase is used for: +- DB provisioning. For backend workflow, we query PostgreSQL directly instead of querying through Supabase's HTTP API. + - This gives us full raw SQL flexibility, with the caveat of managing security by ourselves. + - Thus, we only use this direct connection on the backend, since it is expected to have privileges. +- Auth. Frontend queries Supabase Auth directly and stores credentials, tokens, etc. Backend uses DB secrety key to locally verify if a key is valid, expired, has been tampered with, etc. diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..25b616c --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,414 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "webmun" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud +# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is +# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent. +# auto_expose_new_tables = true + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files, directories, or glob patterns that describe your database. +# Supports paths relative to supabase directory: "./schemas/*.sql", "./database". +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = false +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = false +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +#openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[local_smtp] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = false +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = false + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:5173" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:5173"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ `{{ .Code }}` }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ `{{ .Code }}` }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = false +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = false +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = true +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260719224327_create_sessions_table.sql b/supabase/migrations/20260719224327_create_sessions_table.sql new file mode 100644 index 0000000..4ec0d0c --- /dev/null +++ b/supabase/migrations/20260719224327_create_sessions_table.sql @@ -0,0 +1,19 @@ +create type session_status as enum ('Open', 'Closed'); +create type session_role as enum ('CHAIR', 'DELEGATE'); + +create table sessions ( + id bigint primary key generated always as identity, + name varchar(255), + created_at timestamptz default now(), + status session_status not null default 'Open', + delegations_config JSONB NOT NULL DEFAULT '[]'::jsonb +); + +create table session_assignments ( + user_id uuid not null references auth.users on delete cascade, + session_id bigint not null references sessions(id) on delete cascade, + role session_role not null, + delegation_id int null, + + primary key (user_id, session_id), +); From 1426bb9db28f5a395cce33023569b150a903d2b2 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:18:20 -0300 Subject: [PATCH 02/28] feat: add .env example and db/dependency injections for backend --- .env.example | 8 ++++++++ backend/app/core/config.py | 17 ++++++++++++----- backend/app/core/database.py | 32 ++++++++++++++++++++++++++++++++ backend/app/core/dep.py | 15 +++++++++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 .env.example create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/dep.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6df15a7 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Settings/authService +SUPABASE_URL=http_url_to_supabase +SUPABASE_ANON_KEY=key_here +SUPABASE_JWT_SECRET=jwt_secret here +JWT_ALGORITHM=HS256 + +DATABASE_URL=direct_url_to_supabase + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 95587b8..c826a73 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -6,15 +6,22 @@ class Settings(BaseSettings): # app name APP_NAME: str = "Meu App FastAPI" ENVIRONMENT: str = "development" + + # db config + DATABASE_URL: SecretStr # supabase config - # SUPABASE_URL: AnyHttpUrl - # SUPABASE_ANON_KEY: SecretStr - # SUPABASE_JWT_SECRET: SecretStr - # JWT_ALGORITHM + SUPABASE_URL: AnyHttpUrl + SUPABASE_ANON_KEY: SecretStr + SUPABASE_JWT_SECRET: SecretStr + JWT_ALGORITHM: str = "HS256" # pydantic config to read .env files model_config = SettingsConfigDict( - env_file=".env", + env_file="../../../.env", extra="ignore" ) + +@lru_cache +def get_settings(): + return Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..f83dc92 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,32 @@ +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from sqlalchemy.orm import declarative_base +from app.core.config import get_settings + +settings = get_settings() + +# Gerado pelo Gemini +# Para o Supavisor em modo Transaction, desativamos o prepared statement cache do asyncpg +# passando o parâmetro 'prepared_statement_cache_size=0' na query string ou via connect_args. +engine = create_async_engine( + settings.DATABASE_URL.get_secret_value(), + pool_pre_ping=True, + connect_args={ + "prepared_statement_cache_size": 0 # Crucial para o Supavisor não quebrar! + } +) + +async_sessionmaker_factory = async_sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False +) + +Base = declarative_base() + +# Dependência assíncrona pythônica para o Depends do FastAPI +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + async with async_sessionmaker_factory() as session: + yield session + diff --git a/backend/app/core/dep.py b/backend/app/core/dep.py new file mode 100644 index 0000000..18f6835 --- /dev/null +++ b/backend/app/core/dep.py @@ -0,0 +1,15 @@ +from fastapi import Request +from app.session.manager import ConnectionManager +from app.session.engine import SessionEngine +import logging + +def get_connection_manager(request: Request) -> ConnectionManager: + """Dependency injection for the app connection manager""" + return request.app.state.connection_manager + +def get_session_engine(request: Request) -> SessionEngine: + """Dependency injection for the app session engine""" + return request.app.state.session_engine + +def get_logger() -> logging.Logger: + return logging.getLogger("uvicorn.error") From f5139d5549663228db84d8a3d527d9059ef8d3ee Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:19:50 -0300 Subject: [PATCH 03/28] refactor: switch from factory pattern to pure function this changes old XyzRepository/Service pattern to something that is more common on fastapi/go ecossystems --- backend/app/access/repository.py | 68 ++++++++++++++++---------------- backend/app/access/service.py | 32 +++++++-------- backend/app/auth/service.py | 49 ++++++++++------------- 3 files changed, 69 insertions(+), 80 deletions(-) diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py index abdb7ee..497eb58 100644 --- a/backend/app/access/repository.py +++ b/backend/app/access/repository.py @@ -4,40 +4,40 @@ from sqlalchemy.ext.asyncio import AsyncSession from .models import CommitteeAssignment -class AccessRepository: - def __init__(self, session: AsyncSession): - self.session = session - - async def get_committee_assignment(self, user_id: UUID, session_id: int) -> CommitteeAssignment | None: - query = text(""" - SELECT - sa.user_id, - sa.role, - sa.delegation_id - FROM public.sessions s - JOIN public.session_assignments sa - WHERE s.id = :session_id AND - sa.user_id = :user_id - """) - - result = await self.session.execute( - query, - { - "session_id": session_id, - "user_id": user_id - } - ) - - row = result.mappings().one_or_none() - if row is None: - return None - - return CommitteeAssignment( - user_id=row["user_id"], - session_id=session_id, - role=row["role"], - delegation_id=row["delegation_id"] - ) + +async def get_committee_assignment( + session: AsyncSession, + user_id: UUID, session_id: int +) -> CommitteeAssignment | None: + query = text(""" + SELECT + sa.user_id, + sa.role, + sa.delegation_id + FROM public.sessions s + JOIN public.session_assignments sa + WHERE s.id = :session_id AND + sa.user_id = :user_id + """) + + result = await session.execute( + query, + { + "session_id": session_id, + "user_id": user_id + } + ) + + row = result.mappings().one_or_none() + if row is None: + return None + + return CommitteeAssignment( + user_id=row["user_id"], + session_id=session_id, + role=row["role"], + delegation_id=row["delegation_id"] + ) diff --git a/backend/app/access/service.py b/backend/app/access/service.py index 96c4a17..2bd4f43 100644 --- a/backend/app/access/service.py +++ b/backend/app/access/service.py @@ -1,26 +1,24 @@ from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession from app.access.models import CommitteeAssignment -from app.access.repository import AccessRepository from app.session.enums import SessionRole +from .repository import get_committee_assignment class AccessDenied(Exception): ... -class AccessService: - def __init__(self, repository: AccessRepository): - self.repository = repository - - async def resolve_committee_assignment( - self, - user_id: UUID, - session_id: int, - ) -> CommitteeAssignment: - assignment = await self.repository.get_committee_assignment(user_id, session_id) - - if assignment is None: - raise AccessDenied("User has no committee assignment") +async def resolve_committee_assignment( + session: AsyncSession, + user_id: UUID, + session_id: int, +) -> CommitteeAssignment: + assignment = await get_committee_assignment(session, user_id, session_id) + + if assignment is None: + raise AccessDenied("User has no committee assignment") - if assignment.role == SessionRole.DELEGATE and assignment.delegation_id is None: - raise AccessDenied("Delegate role has no delegation id") + if assignment.role == SessionRole.DELEGATE and assignment.delegation_id is None: + raise AccessDenied("Delegate role has no delegation id") - return assignment + return assignment diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 6fbf18d..56bde53 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,8 +1,4 @@ -from dataclasses import dataclass -from typing import Annotated import jwt -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from uuid import UUID from pydantic import BaseModel from app.core.config import Settings @@ -15,28 +11,23 @@ class AuthUser(BaseModel): class InvalidTokenError(Exception): pass - -class AuthService: - def __init__(self, settings: Settings): - self.settings = settings - - def verify_user_token( - self, - token: str # self contained token that already has info like id, email, etc - ) -> AuthUser: - """Local Verification of user's JWT + Supabase JWT Secret""" - try: - payload = jwt.decode( - token, - self.settings.SUPABASE_JWT_SECRET.get_secret_value(), - self.settings.JWT_ALGORITHM, - audience="authenticated" - ) - - return AuthUser(user_id=UUID(payload.get("id")), email=payload.get("email")) - - except jwt.ExpiredSignatureError: - raise InvalidTokenError("Token has expired") - - except jwt.InvalidTokenError: - raise InvalidTokenError("Token is invalid") +def verify_jwt_token( + token: str, # self contained token that already has info like id, email, etc + settings: Settings +) -> AuthUser: + """Local Verification of user's JWT + Supabase JWT Secret""" + try: + payload = jwt.decode( + token, + settings.SUPABASE_JWT_SECRET.get_secret_value(), + settings.JWT_ALGORITHM, + audience="authenticated" + ) + + return AuthUser(user_id=UUID(payload.get("id")), email=payload.get("email")) + + except jwt.ExpiredSignatureError: + raise InvalidTokenError("Token has expired") + + except jwt.InvalidTokenError: + raise InvalidTokenError("Token is invalid") From 04c894f36ea9a16e16e6e76ba37d27915998eaad Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:20:32 -0300 Subject: [PATCH 04/28] chore: update dependencies --- backend/pyproject.toml | 3 + backend/uv.lock | 861 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 809 insertions(+), 55 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a7a72d4..aedebfd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,6 +8,9 @@ dependencies = [ "fastapi[standard]>=0.135.3", "pytest>=9.1.0", "ruff>=0.15.17", + "supabase", + "pyjwt", + "sqlalchemy" ] [tool.ruff] diff --git a/backend/uv.lock b/backend/uv.lock index 02123fa..702a89b 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -44,15 +44,21 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi", extra = ["standard"] }, + { name = "pyjwt" }, { name = "pytest" }, { name = "ruff" }, + { name = "sqlalchemy" }, + { name = "supabase" }, ] [package.metadata] requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, + { name = "pyjwt" }, { name = "pytest", specifier = ">=9.1.0" }, { name = "ruff", specifier = ">=0.15.17" }, + { name = "sqlalchemy" }, + { name = "supabase" }, ] [[package]] @@ -64,6 +70,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -85,6 +189,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -275,6 +447,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/6d/56ef943ea524784598c035ccbd42e564e937da0438ae3f55f0e76cb95571/fastar-0.11.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6a1c56957ac82408be37a3f63594bc83e0919e8760492a4475e542f9f1828778", size = 1034886, upload-time = "2026-04-13T17:11:15.617Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -284,6 +519,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -348,6 +605,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -473,6 +744,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -491,6 +879,141 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "postgrest" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/22/88c470d8838d2678a44e0172d061630b8837cba3fb7fb492e28f6578c309/postgrest-2.31.0.tar.gz", hash = "sha256:2f395d84b2ee34fc57622ff2f711df603e2ede625f98e5015240741888f7bd0c", size = 14419, upload-time = "2026-06-04T13:37:20.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/3e/41909586cb148db0259e0208310067afda4cc097f4c7a779e829c1e68c46/postgrest-2.31.0-py3-none-any.whl", hash = "sha256:c2fd47c94e13ee8335111c4f03c9a24ea9766ce9d35fc3cd7330057c9e7ea0c3", size = 23098, upload-time = "2026-06-04T13:37:19.452Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.0" @@ -649,6 +1172,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.1.0" @@ -738,6 +1275,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "realtime" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/34/54a1eaaefa24db5cb12596fd74792e08efa53ed30dc5bce2c0a68ded6146/realtime-2.31.0.tar.gz", hash = "sha256:9e641cb4d77ca0fe768515f8cf9f83550c79f49ce1550a95afc2dc0e252be8c9", size = 18716, upload-time = "2026-06-04T13:37:22.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/60/164246615e8b059f6d53d34648a0784260421ec98a07eb1e45160f063221/realtime-2.31.0-py3-none-any.whl", hash = "sha256:f6e494b53d6a6e80b6efcee6711c8dd40413a52e766271de1bce8ced6c36cc1d", size = 22374, upload-time = "2026-06-04T13:37:21.162Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -907,6 +1458,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + [[package]] name = "starlette" version = "1.0.0" @@ -920,6 +1519,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "storage3" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/30/fee43d523d3f680a833a4aae5bf8094de0b9031b0c2bddb3e0bc6e829e1b/storage3-2.31.0.tar.gz", hash = "sha256:d2161e2ea650dc115a1787c30e09b118365589ac772f4dd8643e3a503ecfc667", size = 20348, upload-time = "2026-06-04T13:37:23.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/b2/60d86a3a99ae743e8a00a6df912f85269b3bc882ba217b8ce1690881c669/storage3-2.31.0-py3-none-any.whl", hash = "sha256:4bf46e8bea320743179a6beafdc7531c5242495e00e0cc22af7c7a9d69d4ed84", size = 28492, upload-time = "2026-06-04T13:37:22.792Z" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "supabase" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supabase-auth" }, + { name = "supabase-functions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/8e/54a2f950629689b1613434a61fc3bff5f92f84ba6b20213f5b2add05c1bb/supabase-2.31.0.tar.gz", hash = "sha256:3467b09d00482b9a0138235bdbde7a350426f93cf2a1342372eaddfc669f1206", size = 9805, upload-time = "2026-06-04T13:37:25.22Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/06/5e6f4bf89dedadf81f893832115c1866da1aa093142c9989c2818780dae3/supabase-2.31.0-py3-none-any.whl", hash = "sha256:25f2a99207a75f2d9377e2332783b4389cf56b02cbebdaf0c1743112dcbb704e", size = 16728, upload-time = "2026-06-04T13:37:24.278Z" }, +] + +[[package]] +name = "supabase-auth" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/8a/408689cf39820f0d46d2731d6747ff94dbefc87ae977b4b5c4066da5b070/supabase_auth-2.31.0.tar.gz", hash = "sha256:0945b33fa96239c76dc8eaf96d7d2c94991950d24b4cfe4a5c2da9aa5e909663", size = 39151, upload-time = "2026-06-04T13:37:27.375Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/5e/22f3b0546bb1f0985f06fb1ebf5f6405a3b1bb7a5db01142c26cf148e988/supabase_auth-2.31.0-py3-none-any.whl", hash = "sha256:5e9c8b4ecdee6af04dbcb06455ce78cb15674806fcb6b425170455307d70b0ee", size = 48363, upload-time = "2026-06-04T13:37:26.26Z" }, +] + +[[package]] +name = "supabase-functions" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/5d/61c2446ed26a57fa5543f9c270a731320911569202340d15341f72cdba7c/supabase_functions-2.31.0.tar.gz", hash = "sha256:4ad027b3ae3bd28b31233339f4db1da6965affd3546f655b421baf40cee2690f", size = 4683, upload-time = "2026-06-04T13:37:28.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/79/1a8162ce7d705381a4f2668c0da68e097b103c1aa701418a31da52905c7b/supabase_functions-2.31.0-py3-none-any.whl", hash = "sha256:3fdc4c4766152bfda63bdd0e286fc8a06f50e1280711fae4a1dfc9b7e9ebabc6", size = 8794, upload-time = "2026-06-04T13:37:28.022Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -1116,59 +1785,141 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] From 297da1d6a671db6abb372177e54c23253b700767 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:21:35 -0300 Subject: [PATCH 05/28] refactor: refactors files to use true dependency injection + adds auth based authentication --- backend/app/main.py | 10 +- backend/app/session/engine.py | 9 +- backend/app/session/enums.py | 5 + backend/app/session/manager.py | 2 - backend/app/session/models.py | 9 +- backend/app/session/repository.py | 71 +++++++++++ backend/app/session/schemas.py | 6 +- backend/app/session/service.py | 193 ++++++++++++++++++------------ backend/app/session/views.py | 125 +++++++++++-------- 9 files changed, 280 insertions(+), 150 deletions(-) create mode 100644 backend/app/session/repository.py diff --git a/backend/app/main.py b/backend/app/main.py index 31758d9..2a10293 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,8 +3,7 @@ from app.session.engine import SessionEngine from app.session.manager import ConnectionManager -from app.session.service import SessionService -from app.session.views import create_session_router +from app.session.views import router as session_router app = FastAPI(title="WebMUN API") @@ -18,9 +17,12 @@ manager = ConnectionManager() engine = SessionEngine() -session_service = SessionService(manager, engine) + +# lets manager and engine be long lived +app.state.connection_manager = manager +app.state.engine = engine # include commitees here? app.include_router( - create_session_router(session_service), prefix="/committees", tags=["committees"] + session_router, prefix="/committees", tags=["committees"] ) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index bbe1801..9f49132 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -4,6 +4,7 @@ from typing import Any, TypeAlias import app.session.schemas as schemas +import app.session.enums as enums from .enums import ( ChairEvents, @@ -22,7 +23,6 @@ RollCallContext, SessionActor, SessionLiveState, - SessionRole, VotingContext, ) @@ -134,9 +134,6 @@ class InvalidProceduralMove(Exception): }, } -# we also need related events or automatic/internal cron job in order to change, for example, caucus to GSL -# TODO: change is_chair boolean flag from handlers to something like a Role class, with "DELEGATE", "OBSERVER", "ADMIN" and "CHAIR" types - # Validations and helpers def generate_next_motion_id(state: SessionLiveState) -> int: @@ -211,14 +208,14 @@ def reset_timer(state: SessionLiveState, seconds: int = 0) -> None: def require_delegate(actor: SessionActor) -> DelegationContext: # helper that returns the delegation context (old Delegation model) while validating - if actor.role != SessionRole.DELEGATE or actor.delegation is None: + if actor.role != enums.SessionRole.DELEGATE or actor.delegation is None: raise InvalidProceduralMove("Delegate role required") return actor.delegation def require_chair(actor: SessionActor) -> None: """Returns""" - if actor.role != SessionRole.CHAIR: + if actor.role != enums.SessionRole.CHAIR: raise InvalidProceduralMove("Chair role required") diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py index b9eb534..6e87ff4 100644 --- a/backend/app/session/enums.py +++ b/backend/app/session/enums.py @@ -92,3 +92,8 @@ class RollCallChoice(str, Enum): PRESENT = "Present" PRESENT_AND_VOTING = "Present and Voting" ABSENT = "Absent" + +class SessionRole(str, Enum): + CHAIR = "CHAIR" + DELEGATE = "DELEGATE" + # further roles are put here diff --git a/backend/app/session/manager.py b/backend/app/session/manager.py index b0f1b5b..87b1356 100644 --- a/backend/app/session/manager.py +++ b/backend/app/session/manager.py @@ -8,7 +8,6 @@ class ConnectionManager: - # TODO: refactor additional field 'delegation' when working with auth def __init__(self): # Initialize dictionary with room_name and dict with websocket -> delegation @@ -17,7 +16,6 @@ def __init__(self): # async def connect(self, websocket: WebSocket, session_id: int, actor: SessionActor): - await websocket.accept() self.active_connections.setdefault(session_id, {})[websocket] = actor # when someone connects, send current state as SessionLiveState diff --git a/backend/app/session/models.py b/backend/app/session/models.py index 6f22bf2..c2989e1 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -1,7 +1,6 @@ # This file defines internal models not used as schemas for the application # Even though it's internal, some things may be sent out to public (TODO:like SessionLiveState) from datetime import datetime -from enum import Enum from typing import Literal from pydantic import BaseModel @@ -16,14 +15,8 @@ class DelegationContext(BaseModel): code: str -class SessionRole(str, Enum): - CHAIR = "CHAIR" - DELEGATE = "DELEGATE" - # further roles are put here - - class SessionActor(BaseModel): - role: SessionRole + role: enums.SessionRole display_name: str = "Placeholder" delegation: DelegationContext | None = None diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py new file mode 100644 index 0000000..2492057 --- /dev/null +++ b/backend/app/session/repository.py @@ -0,0 +1,71 @@ +from app.access.models import CommitteeAssignment +from app.session.schemas import DelegationSchema +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from .models import DelegationContext + +class SessionRepository: + def __init__(self, session: AsyncSession): + self.session = session + +async def create_session(session: AsyncSession, name: str, delegations: list[DelegationContext]) -> int | None: + """Creates a session on db""" + + query = text(""" + INSERT INTO public.sessions + (name, delegations_config) + VALUES + (:name, :delegations_list) + RETURNING id + """) + + result = await session.execute( + query, + {"name": name, "delegations_list": [delegation.model_dump(mode="json") for delegation in delegations]}, + ) + + row = result.mappings().one_or_none() + if row is None: + return None + + return row["id"] + +async def bulk_get_uuids_by_email(session: AsyncSession, delegations_schema: list[DelegationSchema]): + """Searches an uuid via email on supabase auth""" + + # selects all (id, email) rows mapping out emails + query = text(""" + SELECT email, id + FROM auth.users + WHERE email = ANY(:emails) + """) + + result = await session.execute( + query, + {"emails": [d.user_email for d in delegations_schema]} + ) + + return {r["email"]: r["id"] for r in result.mappings().all()} + +async def bulk_insert_assignments(session: AsyncSession, delegations: list[CommitteeAssignment]): + """Inserts via bulk all (uuid, delegation, session_id) rows""" + params = [ + { + "user_id": d.user_id, + "session_id": d.session_id, + "role": d.role, + "delegation_id": d.delegation_id, + } + for d in delegations + ] + + query = text(""" + INSERT INTO public.session_assignments (user_id, session_id, role, delegation_id) + VALUES (:user_id, :session_id, :role, :delegation_id) + """) + + await session.execute( + query, + params, + ) + diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index b30cd2a..f9162fd 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -1,21 +1,19 @@ from typing import Annotated, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, EmailStr import app.session.enums as enums # --- General Schemas --- - -# should be used to create list of delegations. class DelegationSchema(BaseModel): + user_email: EmailStr # TODO: enable multiple users per delegation name: str seat: str # TODO: check if its better to use an int here on the frontend code: str # TODO: check if this is needed class SessionCreationSchema(BaseModel): - session_id: int name: str | None = None delegations: list[DelegationSchema] # useful for now diff --git a/backend/app/session/service.py b/backend/app/session/service.py index ea2e824..a2de042 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -4,10 +4,16 @@ import logging from datetime import datetime +from typing import Any +from uuid import UUID +from fastapi import HTTPException, status from pydantic import TypeAdapter +from sqlalchemy.ext.asyncio import AsyncSession +from app.access.models import CommitteeAssignment import app.session.enums as enums +from app.session.repository import bulk_get_uuids_by_email, bulk_insert_assignments, create_session import app.session.schemas as schemas from app.session.engine import SessionEngine @@ -17,94 +23,127 @@ RollCallContext, SessionActor, SessionLiveState, - SessionRole, ) -from .enums import States - class ActorResolutionError(Exception): pass +class SessionCreationError(Exception): + pass # SessionService class defined with Dependency Injection -class SessionService: - def __init__(self, manager: ConnectionManager, engine: SessionEngine): - self.manager = manager - self.engine = engine - self.uvicorn_logger = logging.getLogger("uvicorn.error") - - def build_actor( - self, - session_id: int, - role: SessionRole, - delegation_id: int | None = None, - display_name: str | None = None, - ) -> SessionActor: - - if role == SessionRole.CHAIR: - return SessionActor( - role=SessionRole.CHAIR, - display_name="Chair", - ) - - if role == SessionRole.DELEGATE: - if delegation_id is None: - raise ActorResolutionError("needs delegate id") - state = self.manager.room_states.get(session_id) - if state is None: - raise ActorResolutionError("session not found") - - delegation = next( - (d for d in state.delegations if d.id == delegation_id), None - ) - if delegation is None: - raise ActorResolutionError("delegation not found") - - return SessionActor( - role=SessionRole.DELEGATE, - delegation=DelegationContext( - id=delegation.id, - seat=delegation.seat, - name=delegation.name, - code=delegation.code, - ), - display_name=delegation.name, - ) - - def create_session(self, session_schema: schemas.SessionCreationSchema): - session_id = session_schema.session_id - - # loop through delegationSchema and convert each to DelegationContext - delegations = [ - # id now begins at 0 instead of 1 - DelegationContext(id=i, name=d.name, seat=d.seat, code=d.code) - for i, d in enumerate(session_schema.delegations) - ] - - self.manager.room_states[session_id] = SessionLiveState( - session_id=session_id, - start_time=datetime.now(), - delegations=delegations, - current_state=States.SETUP, - gsl_default_time_seconds=60, - roll_call=RollCallContext(registry={}), - voting_choice={}, - ) - self.manager.active_connections.setdefault(session_id, {}) - async def handle_client_messages(self, session_id: int, actor: SessionActor, data): - adapter = TypeAdapter(schemas.SessionEvent) +def build_actor( + manager: ConnectionManager, + session_id: int, + role: enums.SessionRole, + delegation_id: int | None = None, +) -> SessionActor: + + if role == enums.SessionRole.CHAIR: + return SessionActor( + role=enums.SessionRole.CHAIR, + display_name="Chair", + ) - # if schema is None: - # raise ValueError("Unsupported event type") + if role == enums.SessionRole.DELEGATE: + if delegation_id is None: + raise ActorResolutionError("needs delegate id") + state = manager.room_states.get(session_id) + if state is None: + raise ActorResolutionError("session not found") - event = adapter.validate_json(data) - state = self.manager.room_states[session_id] + delegation = next( + (d for d in state.delegations if d.id == delegation_id), None + ) + if delegation is None: + raise ActorResolutionError("delegation not found") + + return SessionActor( + role=enums.SessionRole.DELEGATE, + delegation=DelegationContext( + id=delegation.id, + seat=delegation.seat, + name=delegation.name, + code=delegation.code, + ), + display_name=delegation.name, + ) - self.uvicorn_logger.info(event) # Debugging - new_state = self.engine.dispatch(state, event, actor) - self.manager.room_states[session_id] = new_state +async def create_session_service( + session: AsyncSession, + manager: ConnectionManager, + session_schema: schemas.SessionCreationSchema, + creator_uuid: UUID): + # loop through delegationSchema and convert each to DelegationContext + delegations = [ + # id now begins at 0 instead of 1 + DelegationContext(id=i, name=d.name, seat=d.seat, code=d.code) + for i, d in enumerate(session_schema.delegations) + ] + + # insert into database and return the session id here + session_id = await create_session(session=session, name=session_schema.name or "", delegations=delegations) + if session_id is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Could not create session with given schema" + ) + + emails_to_uuid_map = await bulk_get_uuids_by_email(session, session_schema.delegations) + if len(delegations) != len(emails_to_uuid_map): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Some users were not found" + ) - await self.manager.broadcast_state(session_id) + final_delegations = [ + CommitteeAssignment( + user_id=emails_to_uuid_map[d.user_email], + session_id=session_id, + delegation_id=i, # R: I hope this works since I think enumerate is deterministic + role="DELEGATE", + ) + for i, d in enumerate(session_schema.delegations) + ] + + # append this chair at last so we can also retrieve it + final_delegations.append(CommitteeAssignment(user_id=creator_uuid, session_id=session_id, delegation_id=None,role="CHAIR")) + + await bulk_insert_assignments(session=session, delegations=final_delegations) + + + manager.room_states[session_id] = SessionLiveState( + session_id=session_id, + start_time=datetime.now(), + delegations=delegations, + current_state=enums.States.SETUP, + gsl_default_time_seconds=60, + roll_call=RollCallContext(registry={}), + voting_choice={}, + ) + manager.active_connections.setdefault(session_id, {}) + +async def handle_client_messages( + manager: ConnectionManager, + engine: SessionEngine, + logger: logging.Logger, + session_id: int, + actor: SessionActor, + data): + adapter = TypeAdapter(schemas.SessionEvent) + + # if schema is None: + # raise ValueError("Unsupported event type") + + event = adapter.validate_json(data) + state = manager.room_states[session_id] + + logger.info(event) # Debugging + + new_state = engine.dispatch(state, event, actor) + manager.room_states[session_id] = new_state + + await manager.broadcast_state(session_id) diff --git a/backend/app/session/views.py b/backend/app/session/views.py index d5b4d6a..0ea4330 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -3,77 +3,104 @@ from fastapi import ( APIRouter, + Depends, Response, WebSocket, WebSocketDisconnect, - WebSocketException, status, ) +from jwt.exceptions import InvalidTokenError +from sqlalchemy.ext.asyncio import AsyncSession -from app.session.enums import ChairEvents, DelegateEvents -from app.session.models import SessionLiveState, SessionRole +from app.access.service import AccessDenied +from app.auth.service import verify_jwt_token +from app.core.config import Settings +from app.core.dep import get_connection_manager, get_logger, get_session_engine +from app.core.config import get_settings +from app.core.database import get_db_session +from app.session.enums import ChairEvents, DelegateEvents, SessionRole +from app.session.models import SessionLiveState from app.session.schemas import SessionCreationSchema, SessionEvent -from .service import ( - ActorResolutionError, - SessionService, -) - - -# We'll use the Factory design patter here, by receiving the service object and returning -# the whole router with the service injected on it -def create_session_router(service: SessionService) -> APIRouter: - router = APIRouter() +import app.access.service as access +import app.session.service as service - # Workaround to make FastApi add all the Schemas to the OpenApi file - @router.get("/dummy", status_code=status.HTTP_404_NOT_FOUND) - async def dummy( - name: SessionEvent, - schemas: SessionLiveState, - enum1: DelegateEvents, - enum2: ChairEvents, - ): - return Response(status_code=status.HTTP_404_NOT_FOUND) +router = APIRouter() + +# Workaround to make FastApi add all the Schemas to the OpenApi file +@router.get("/dummy", status_code=status.HTTP_404_NOT_FOUND) +async def dummy( + name: SessionEvent, + schemas: SessionLiveState, + enum1: DelegateEvents, + enum2: ChairEvents, +): + return Response(status_code=status.HTTP_404_NOT_FOUND) - @router.get("/health", status_code=status.HTTP_200_OK) - async def health(): - return Response(status_code=status.HTTP_200_OK) +@router.get("/health", status_code=status.HTTP_200_OK) +async def health(): + return Response(status_code=status.HTTP_200_OK) - # Create committee route, receives a contentbody following CommitteeCreationSchema's format - @router.post("/", status_code=status.HTTP_204_NO_CONTENT) - async def create_session_endpoint(session_schema: SessionCreationSchema): - # Mock a session being created - service.create_session(session_schema) +# Create committee route, receives a contentbody following CommitteeCreationSchema's format +@router.post("/", status_code=status.HTTP_204_NO_CONTENT) +async def create_session_endpoint( + session_schema: SessionCreationSchema, + session: AsyncSession = Depends(get_db_session), + manager = Depends(get_connection_manager), + current_user = Depends(verify_jwt_token), +): + # Mock a session being created + await service.create_session_service(manager=manager, session=session, session_schema=session_schema, creator_uuid=current_user.id) - return Response(status_code=status.HTTP_204_NO_CONTENT) + return Response(status_code=status.HTTP_204_NO_CONTENT) - @router.websocket("/ws/{session_id}") - async def websocket_endpoint( - websocket: WebSocket, - session_id: int, - role: SessionRole, - delegation_id: int | None = None, - display_name: str | None = None, - ): - - try: - actor = service.build_actor(session_id, role, delegation_id, display_name) - except ActorResolutionError as exc: - raise WebSocketException( - code=status.WS_1008_POLICY_VIOLATION, reason=str(exc) - ) +@router.websocket("/ws/{session_id}") +async def websocket_endpoint( + websocket: WebSocket, + session_id: int, + manager = Depends(get_connection_manager), + engine = Depends(get_session_engine), + logger = Depends(get_logger), + session: AsyncSession = Depends(get_db_session), + settings: Settings = Depends(get_settings), +): + await websocket.accept() + try: + auth_message = await websocket.receive_json() + auth_user = verify_jwt_token( + settings = settings, + token=auth_message["access_token"] + ) + + assignment = await access.resolve_committee_assignment( + session=session, + user_id=auth_user.user_id, + session_id=session_id + ) + + actor = service.build_actor( + manager=manager, + session_id=session_id, + role=SessionRole(assignment.role), + delegation_id=assignment.delegation_id, + ) - await service.manager.connect(websocket, session_id, actor) + await manager.connect(websocket, session_id, actor) try: while True: data = await websocket.receive_text() await service.handle_client_messages( + manager=manager, + engine=engine, + logger=logger, session_id=session_id, actor=actor, data=data, ) except WebSocketDisconnect: - service.manager.disconnect(websocket, session_id) + manager.disconnect(websocket, session_id) - return router + except (InvalidTokenError, AccessDenied, service.ActorResolutionError): + await websocket.close(code=1008) + return From d8202fcbc0123a19fcd3d358e9531e77989cca1e Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:24:25 -0300 Subject: [PATCH 06/28] style: formats code with ruff format --- backend/app/access/models.py | 6 +- backend/app/access/repository.py | 14 +--- backend/app/access/service.py | 11 +-- backend/app/access/tests/test_access.py | 2 +- backend/app/auth/service.py | 8 ++- backend/app/core/config.py | 15 ++-- backend/app/core/database.py | 9 +-- backend/app/core/dep.py | 3 + backend/app/main.py | 4 +- backend/app/session/engine.py | 30 +++++--- backend/app/session/enums.py | 1 + backend/app/session/manager.py | 7 +- backend/app/session/repository.py | 35 +++++++--- backend/app/session/schemas.py | 3 +- backend/app/session/service.py | 59 ++++++++++------ backend/app/session/views.py | 40 ++++++----- backend/app/tests/conftest.py | 2 + backend/app/tests/session/test_service.py | 84 ++++++++++------------- 18 files changed, 183 insertions(+), 150 deletions(-) diff --git a/backend/app/access/models.py b/backend/app/access/models.py index c86c75f..12c9dd3 100644 --- a/backend/app/access/models.py +++ b/backend/app/access/models.py @@ -2,10 +2,12 @@ from typing import Literal from uuid import UUID + @dataclass(frozen=True) class CommitteeAssignment: - """Object that holds info about an UUID to a commitee and Delegation / Chair """ + """Object that holds info about an UUID to a commitee and Delegation / Chair""" + user_id: UUID - session_id: int # TODO: remove this to map out to committees/conferences + session_id: int # TODO: remove this to map out to committees/conferences role: Literal["CHAIR", "DELEGATE"] delegation_id: int | None diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py index 497eb58..b8963dc 100644 --- a/backend/app/access/repository.py +++ b/backend/app/access/repository.py @@ -6,8 +6,7 @@ async def get_committee_assignment( - session: AsyncSession, - user_id: UUID, session_id: int + session: AsyncSession, user_id: UUID, session_id: int ) -> CommitteeAssignment | None: query = text(""" SELECT @@ -21,11 +20,7 @@ async def get_committee_assignment( """) result = await session.execute( - query, - { - "session_id": session_id, - "user_id": user_id - } + query, {"session_id": session_id, "user_id": user_id} ) row = result.mappings().one_or_none() @@ -36,8 +31,5 @@ async def get_committee_assignment( user_id=row["user_id"], session_id=session_id, role=row["role"], - delegation_id=row["delegation_id"] + delegation_id=row["delegation_id"], ) - - - diff --git a/backend/app/access/service.py b/backend/app/access/service.py index 2bd4f43..2c3ab80 100644 --- a/backend/app/access/service.py +++ b/backend/app/access/service.py @@ -5,17 +5,18 @@ from app.session.enums import SessionRole from .repository import get_committee_assignment -class AccessDenied(Exception): - ... + +class AccessDenied(Exception): ... + async def resolve_committee_assignment( session: AsyncSession, user_id: UUID, - session_id: int, + session_id: int, ) -> CommitteeAssignment: assignment = await get_committee_assignment(session, user_id, session_id) - - if assignment is None: + + if assignment is None: raise AccessDenied("User has no committee assignment") if assignment.role == SessionRole.DELEGATE and assignment.delegation_id is None: diff --git a/backend/app/access/tests/test_access.py b/backend/app/access/tests/test_access.py index 860673b..5871ed8 100644 --- a/backend/app/access/tests/test_access.py +++ b/backend/app/access/tests/test_access.py @@ -1 +1 @@ -import pytest +import pytest diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 56bde53..12c2235 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -8,12 +8,14 @@ class AuthUser(BaseModel): user_id: UUID email: str | None + class InvalidTokenError(Exception): pass + def verify_jwt_token( - token: str, # self contained token that already has info like id, email, etc - settings: Settings + token: str, # self contained token that already has info like id, email, etc + settings: Settings, ) -> AuthUser: """Local Verification of user's JWT + Supabase JWT Secret""" try: @@ -21,7 +23,7 @@ def verify_jwt_token( token, settings.SUPABASE_JWT_SECRET.get_secret_value(), settings.JWT_ALGORITHM, - audience="authenticated" + audience="authenticated", ) return AuthUser(user_id=UUID(payload.get("id")), email=payload.get("email")) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c826a73..6c11776 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -2,25 +2,24 @@ from pydantic import AnyHttpUrl, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict + class Settings(BaseSettings): - # app name + # app name APP_NAME: str = "Meu App FastAPI" ENVIRONMENT: str = "development" - # db config + # db config DATABASE_URL: SecretStr - + # supabase config SUPABASE_URL: AnyHttpUrl SUPABASE_ANON_KEY: SecretStr SUPABASE_JWT_SECRET: SecretStr JWT_ALGORITHM: str = "HS256" - + # pydantic config to read .env files - model_config = SettingsConfigDict( - env_file="../../../.env", - extra="ignore" - ) + model_config = SettingsConfigDict(env_file="../../../.env", extra="ignore") + @lru_cache def get_settings(): diff --git a/backend/app/core/database.py b/backend/app/core/database.py index f83dc92..6d991ac 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -13,20 +13,17 @@ pool_pre_ping=True, connect_args={ "prepared_statement_cache_size": 0 # Crucial para o Supavisor não quebrar! - } + }, ) async_sessionmaker_factory = async_sessionmaker( - bind=engine, - autocommit=False, - autoflush=False, - expire_on_commit=False + bind=engine, autocommit=False, autoflush=False, expire_on_commit=False ) Base = declarative_base() + # Dependência assíncrona pythônica para o Depends do FastAPI async def get_db_session() -> AsyncGenerator[AsyncSession, None]: async with async_sessionmaker_factory() as session: yield session - diff --git a/backend/app/core/dep.py b/backend/app/core/dep.py index 18f6835..b3f27d7 100644 --- a/backend/app/core/dep.py +++ b/backend/app/core/dep.py @@ -3,13 +3,16 @@ from app.session.engine import SessionEngine import logging + def get_connection_manager(request: Request) -> ConnectionManager: """Dependency injection for the app connection manager""" return request.app.state.connection_manager + def get_session_engine(request: Request) -> SessionEngine: """Dependency injection for the app session engine""" return request.app.state.session_engine + def get_logger() -> logging.Logger: return logging.getLogger("uvicorn.error") diff --git a/backend/app/main.py b/backend/app/main.py index 2a10293..837f887 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,4 @@ app.state.engine = engine # include commitees here? -app.include_router( - session_router, prefix="/committees", tags=["committees"] -) +app.include_router(session_router, prefix="/committees", tags=["committees"]) diff --git a/backend/app/session/engine.py b/backend/app/session/engine.py index 9f49132..e8ecf80 100644 --- a/backend/app/session/engine.py +++ b/backend/app/session/engine.py @@ -350,10 +350,12 @@ def handle_answer_roll_call( # Chair events -def handle_open_session(state: SessionLiveState, event: schemas.OpenSessionEvent, actor: SessionActor) -> SessionLiveState: - +def handle_open_session( + state: SessionLiveState, event: schemas.OpenSessionEvent, actor: SessionActor +) -> SessionLiveState: + require_chair(actor) - if (state.current_state != States.SETUP): + if state.current_state != States.SETUP: raise InvalidProceduralMove("Session can only be opened from setup") state.current_state = States.ROLL_CALL @@ -368,24 +370,30 @@ def handle_open_session(state: SessionLiveState, event: schemas.OpenSessionEvent return state -def handle_close_session(state: SessionLiveState, event: schemas.CloseSessionEvent, actor: SessionActor) -> SessionLiveState: - + +def handle_close_session( + state: SessionLiveState, event: schemas.CloseSessionEvent, actor: SessionActor +) -> SessionLiveState: + require_chair(actor) - - if (state.current_state not in (States.SETUP, States.ROLL_CALL, States.FINISHED)): # idk what state is best to allow closing session, but for now i'll allow closing from any state other than SETUP, ROLl_CALL and FINISHED itself + + if ( + state.current_state not in (States.SETUP, States.ROLL_CALL, States.FINISHED) + ): # idk what state is best to allow closing session, but for now i'll allow closing from any state other than SETUP, ROLl_CALL and FINISHED itself raise InvalidProceduralMove("Session can only be opened from setup") state.current_state = States.FINISHED state.current_speaker = None - state.gsl_queue = [] # I'm supposing this queue has the first element popped when someone speaks, so it should be empty when session is closed. In case this list is to be kept, we can remove this line. + state.gsl_queue = [] # I'm supposing this queue has the first element popped when someone speaks, so it should be empty when session is closed. In case this list is to be kept, we can remove this line. state.can_set_motion = False - state.debate = None # Same as queue + state.debate = None # Same as queue state.timer_is_running = False state.timer_expiration = None state.timer_remaining_seconds = 0 return state + # TODO: create helpers for timers -> stop_timer, set_timer, pause_timer, etc def handle_toggle_timer( state: SessionLiveState, event: schemas.ToggleTimerEvent, actor: SessionActor @@ -700,8 +708,8 @@ def handle_close_roll_call( require_chair(actor) if state.current_state != States.ROLL_CALL or state.roll_call is None: raise InvalidProceduralMove("Cannot close roll call right now") - - # mark all delegations as absent in the first case. This will enable us to use + + # mark all delegations as absent in the first case. This will enable us to use # RollCallContext to tally votes for delegation in state.delegations: state.roll_call.registry.setdefault(delegation.id, RollCallChoice.ABSENT) diff --git a/backend/app/session/enums.py b/backend/app/session/enums.py index 6e87ff4..0205ae6 100644 --- a/backend/app/session/enums.py +++ b/backend/app/session/enums.py @@ -93,6 +93,7 @@ class RollCallChoice(str, Enum): PRESENT_AND_VOTING = "Present and Voting" ABSENT = "Absent" + class SessionRole(str, Enum): CHAIR = "CHAIR" DELEGATE = "DELEGATE" diff --git a/backend/app/session/manager.py b/backend/app/session/manager.py index 87b1356..b099405 100644 --- a/backend/app/session/manager.py +++ b/backend/app/session/manager.py @@ -8,7 +8,6 @@ class ConnectionManager: - def __init__(self): # Initialize dictionary with room_name and dict with websocket -> delegation self.active_connections: dict[int, dict[WebSocket, SessionActor]] = {} @@ -37,9 +36,11 @@ def count_connected(self, session_id: int): def count_present_delegations(self, session_id: int) -> int: """Count unique delegations currently connected to the session.""" actors = self.active_connections.get(session_id, {}).values() - return len({actor.delegation.id for actor in actors if actor.delegation is not None}) + return len( + {actor.delegation.id for actor in actors if actor.delegation is not None} + ) - # More things from connection manager here + # More things from connection manager here async def broadcast_state(self, session_id: int): """Sends current state to all clients in the room""" state = self.room_states.get(session_id) diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py index 2492057..dc01e21 100644 --- a/backend/app/session/repository.py +++ b/backend/app/session/repository.py @@ -4,11 +4,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from .models import DelegationContext + class SessionRepository: def __init__(self, session: AsyncSession): self.session = session -async def create_session(session: AsyncSession, name: str, delegations: list[DelegationContext]) -> int | None: + +async def create_session( + session: AsyncSession, name: str, delegations: list[DelegationContext] +) -> int | None: """Creates a session on db""" query = text(""" @@ -20,17 +24,25 @@ async def create_session(session: AsyncSession, name: str, delegations: list[Del """) result = await session.execute( - query, - {"name": name, "delegations_list": [delegation.model_dump(mode="json") for delegation in delegations]}, + query, + { + "name": name, + "delegations_list": [ + delegation.model_dump(mode="json") for delegation in delegations + ], + }, ) - + row = result.mappings().one_or_none() if row is None: return None return row["id"] - -async def bulk_get_uuids_by_email(session: AsyncSession, delegations_schema: list[DelegationSchema]): + + +async def bulk_get_uuids_by_email( + session: AsyncSession, delegations_schema: list[DelegationSchema] +): """Searches an uuid via email on supabase auth""" # selects all (id, email) rows mapping out emails @@ -41,13 +53,15 @@ async def bulk_get_uuids_by_email(session: AsyncSession, delegations_schema: lis """) result = await session.execute( - query, - {"emails": [d.user_email for d in delegations_schema]} + query, {"emails": [d.user_email for d in delegations_schema]} ) return {r["email"]: r["id"] for r in result.mappings().all()} -async def bulk_insert_assignments(session: AsyncSession, delegations: list[CommitteeAssignment]): + +async def bulk_insert_assignments( + session: AsyncSession, delegations: list[CommitteeAssignment] +): """Inserts via bulk all (uuid, delegation, session_id) rows""" params = [ { @@ -65,7 +79,6 @@ async def bulk_insert_assignments(session: AsyncSession, delegations: list[Commi """) await session.execute( - query, + query, params, ) - diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index f9162fd..1d78db5 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -6,8 +6,9 @@ # --- General Schemas --- + class DelegationSchema(BaseModel): - user_email: EmailStr # TODO: enable multiple users per delegation + user_email: EmailStr # TODO: enable multiple users per delegation name: str seat: str # TODO: check if its better to use an int here on the frontend code: str # TODO: check if this is needed diff --git a/backend/app/session/service.py b/backend/app/session/service.py index a2de042..e1fec1d 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -13,7 +13,11 @@ from app.access.models import CommitteeAssignment import app.session.enums as enums -from app.session.repository import bulk_get_uuids_by_email, bulk_insert_assignments, create_session +from app.session.repository import ( + bulk_get_uuids_by_email, + bulk_insert_assignments, + create_session, +) import app.session.schemas as schemas from app.session.engine import SessionEngine @@ -29,11 +33,14 @@ class ActorResolutionError(Exception): pass + class SessionCreationError(Exception): pass + # SessionService class defined with Dependency Injection + def build_actor( manager: ConnectionManager, session_id: int, @@ -54,9 +61,7 @@ def build_actor( if state is None: raise ActorResolutionError("session not found") - delegation = next( - (d for d in state.delegations if d.id == delegation_id), None - ) + delegation = next((d for d in state.delegations if d.id == delegation_id), None) if delegation is None: raise ActorResolutionError("delegation not found") @@ -73,10 +78,11 @@ def build_actor( async def create_session_service( - session: AsyncSession, - manager: ConnectionManager, - session_schema: schemas.SessionCreationSchema, - creator_uuid: UUID): + session: AsyncSession, + manager: ConnectionManager, + session_schema: schemas.SessionCreationSchema, + creator_uuid: UUID, +): # loop through delegationSchema and convert each to DelegationContext delegations = [ # id now begins at 0 instead of 1 @@ -85,36 +91,45 @@ async def create_session_service( ] # insert into database and return the session id here - session_id = await create_session(session=session, name=session_schema.name or "", delegations=delegations) + session_id = await create_session( + session=session, name=session_schema.name or "", delegations=delegations + ) if session_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Could not create session with given schema" + detail="Could not create session with given schema", ) - - emails_to_uuid_map = await bulk_get_uuids_by_email(session, session_schema.delegations) + + emails_to_uuid_map = await bulk_get_uuids_by_email( + session, session_schema.delegations + ) if len(delegations) != len(emails_to_uuid_map): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Some users were not found" + status_code=status.HTTP_400_BAD_REQUEST, detail="Some users were not found" ) final_delegations = [ CommitteeAssignment( user_id=emails_to_uuid_map[d.user_email], session_id=session_id, - delegation_id=i, # R: I hope this works since I think enumerate is deterministic + delegation_id=i, # R: I hope this works since I think enumerate is deterministic role="DELEGATE", ) for i, d in enumerate(session_schema.delegations) ] - + # append this chair at last so we can also retrieve it - final_delegations.append(CommitteeAssignment(user_id=creator_uuid, session_id=session_id, delegation_id=None,role="CHAIR")) + final_delegations.append( + CommitteeAssignment( + user_id=creator_uuid, + session_id=session_id, + delegation_id=None, + role="CHAIR", + ) + ) await bulk_insert_assignments(session=session, delegations=final_delegations) - manager.room_states[session_id] = SessionLiveState( session_id=session_id, start_time=datetime.now(), @@ -126,13 +141,15 @@ async def create_session_service( ) manager.active_connections.setdefault(session_id, {}) + async def handle_client_messages( manager: ConnectionManager, engine: SessionEngine, logger: logging.Logger, - session_id: int, - actor: SessionActor, - data): + session_id: int, + actor: SessionActor, + data, +): adapter = TypeAdapter(schemas.SessionEvent) # if schema is None: diff --git a/backend/app/session/views.py b/backend/app/session/views.py index 0ea4330..bcfd0d9 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -26,7 +26,8 @@ import app.session.service as service router = APIRouter() - + + # Workaround to make FastApi add all the Schemas to the OpenApi file @router.get("/dummy", status_code=status.HTTP_404_NOT_FOUND) async def dummy( @@ -37,30 +38,38 @@ async def dummy( ): return Response(status_code=status.HTTP_404_NOT_FOUND) + @router.get("/health", status_code=status.HTTP_200_OK) async def health(): return Response(status_code=status.HTTP_200_OK) + # Create committee route, receives a contentbody following CommitteeCreationSchema's format @router.post("/", status_code=status.HTTP_204_NO_CONTENT) async def create_session_endpoint( - session_schema: SessionCreationSchema, - session: AsyncSession = Depends(get_db_session), - manager = Depends(get_connection_manager), - current_user = Depends(verify_jwt_token), + session_schema: SessionCreationSchema, + session: AsyncSession = Depends(get_db_session), + manager=Depends(get_connection_manager), + current_user=Depends(verify_jwt_token), ): # Mock a session being created - await service.create_session_service(manager=manager, session=session, session_schema=session_schema, creator_uuid=current_user.id) + await service.create_session_service( + manager=manager, + session=session, + session_schema=session_schema, + creator_uuid=current_user.id, + ) return Response(status_code=status.HTTP_204_NO_CONTENT) + @router.websocket("/ws/{session_id}") async def websocket_endpoint( websocket: WebSocket, session_id: int, - manager = Depends(get_connection_manager), - engine = Depends(get_session_engine), - logger = Depends(get_logger), + manager=Depends(get_connection_manager), + engine=Depends(get_session_engine), + logger=Depends(get_logger), session: AsyncSession = Depends(get_db_session), settings: Settings = Depends(get_settings), ): @@ -68,16 +77,13 @@ async def websocket_endpoint( try: auth_message = await websocket.receive_json() auth_user = verify_jwt_token( - settings = settings, - token=auth_message["access_token"] + settings=settings, token=auth_message["access_token"] ) - + assignment = await access.resolve_committee_assignment( - session=session, - user_id=auth_user.user_id, - session_id=session_id - ) - + session=session, user_id=auth_user.user_id, session_id=session_id + ) + actor = service.build_actor( manager=manager, session_id=session_id, diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index c4425d6..3408b4a 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -14,6 +14,8 @@ RollCallContext, SessionActor, SessionLiveState, +) +from app.session.enums import ( SessionRole, ) from app.session.service import SessionService diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index d663a60..4ab50da 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -17,16 +17,13 @@ def delegation_schema_list(): return [brazil, usa, russia] -@pytest.fixture +@pytest.fixture def session_test_schema(delegation_schema_list: list[DelegationSchema]): - return SessionCreationSchema( - session_id=0, - delegations=delegation_schema_list - ) + return SessionCreationSchema(session_id=0, delegations=delegation_schema_list) def test_can_build_actor( - service: SessionService, + service: SessionService, session_state: SessionLiveState, ) -> None: service.manager.room_states[0] = session_state @@ -44,40 +41,41 @@ def test_can_build_actor( def test_cannot_build_actor_with_nonexistent_state( - service: SessionService, + service: SessionService, ) -> None: with pytest.raises(ActorResolutionError, match="session not found"): - service.build_actor( - session_id=0, - role=SessionRole.DELEGATE, - delegation_id=0, - ) + service.build_actor( + session_id=0, + role=SessionRole.DELEGATE, + delegation_id=0, + ) def test_cannot_build_actor_with_no_delegation_id( - service: SessionService, + service: SessionService, session_state: SessionLiveState, ) -> None: with pytest.raises(ActorResolutionError, match="needs delegate id"): - service.manager.room_states[0] = session_state - service.build_actor( - session_id=0, - role=SessionRole.DELEGATE, - ) + service.manager.room_states[0] = session_state + service.build_actor( + session_id=0, + role=SessionRole.DELEGATE, + ) def test_cannot_build_actor_with_nonexistent_delegation( - service: SessionService, + service: SessionService, session_state: SessionLiveState, ) -> None: with pytest.raises(ActorResolutionError, match="delegation not found"): - service.manager.room_states[0] = session_state + service.manager.room_states[0] = session_state + + service.build_actor( + session_id=0, + role=SessionRole.DELEGATE, + delegation_id=999, + ) - service.build_actor( - session_id=0, - role=SessionRole.DELEGATE, - delegation_id=999, - ) def test_can_create_session( service: SessionService, @@ -93,9 +91,9 @@ def test_can_create_session( @pytest.mark.anyio async def test_handle_client_messages_dispatches_and_broadcasts( - service: SessionService, + service: SessionService, session_state: SessionLiveState, - delegate_actor: SessionActor, + delegate_actor: SessionActor, ) -> None: session_state.current_state = enums.States.OPEN_GSL service.manager.room_states[session_state.session_id] = session_state @@ -104,32 +102,24 @@ async def test_handle_client_messages_dispatches_and_broadcasts( async def fake_broadcast_state(session_id: int): broadcasts.append(session_id) - + # replaces real broadcast state with this one service.manager.broadcast_state = fake_broadcast_state - data = json.dumps({ - "type": enums.DelegateEvents.JOIN_QUEUE, - "payload": {}, - }) + data = json.dumps( + { + "type": enums.DelegateEvents.JOIN_QUEUE, + "payload": {}, + } + ) await service.handle_client_messages( - session_id=session_state.session_id, - actor=delegate_actor, - data=data + session_id=session_state.session_id, actor=delegate_actor, data=data ) - # tests if engine dispatched the message + # tests if engine dispatched the message - assert service.engine.dispatched["state"] is session_state # type: ignore - assert service.engine.dispatched["event"].type == enums.DelegateEvents.JOIN_QUEUE # type: ignore - assert service.engine.dispatched["actor"] is delegate_actor # type: ignore + assert service.engine.dispatched["state"] is session_state # type: ignore + assert service.engine.dispatched["event"].type == enums.DelegateEvents.JOIN_QUEUE # type: ignore + assert service.engine.dispatched["actor"] is delegate_actor # type: ignore assert broadcasts == [session_state.session_id] - - - - - - - - From 3505b2ebcc213883c20e4615534f81e9f2764168 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:45:58 -0300 Subject: [PATCH 07/28] fix: fix migrations bug --- .env.example | 1 - backend/app/core/config.py | 1 - supabase/config.toml | 2 +- supabase/migrations/20260719224327_create_sessions_table.sql | 2 +- 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 6df15a7..b628822 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ # Settings/authService SUPABASE_URL=http_url_to_supabase -SUPABASE_ANON_KEY=key_here SUPABASE_JWT_SECRET=jwt_secret here JWT_ALGORITHM=HS256 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6c11776..d10fc6c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -13,7 +13,6 @@ class Settings(BaseSettings): # supabase config SUPABASE_URL: AnyHttpUrl - SUPABASE_ANON_KEY: SecretStr SUPABASE_JWT_SECRET: SecretStr JWT_ALGORITHM: str = "HS256" diff --git a/supabase/config.toml b/supabase/config.toml index 25b616c..c09ed5a 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -92,7 +92,7 @@ enabled = false # max_header_length = 4096 [studio] -enabled = false +enabled = true # Port to use for Supabase Studio. port = 54323 # External URL of the API server that frontend connects to. diff --git a/supabase/migrations/20260719224327_create_sessions_table.sql b/supabase/migrations/20260719224327_create_sessions_table.sql index 4ec0d0c..18409ba 100644 --- a/supabase/migrations/20260719224327_create_sessions_table.sql +++ b/supabase/migrations/20260719224327_create_sessions_table.sql @@ -15,5 +15,5 @@ create table session_assignments ( role session_role not null, delegation_id int null, - primary key (user_id, session_id), + primary key (user_id, session_id) ); From 5649325fcae61219e375c2181dfa2be4da8100e6 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:24:07 -0300 Subject: [PATCH 08/28] test: adds initial seed.sql for testing purposes --- supabase/seed.sql | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 supabase/seed.sql diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..8dd62ef --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,63 @@ +-- simple session insertion +insert into public.sessions + (name, delegations_config) +values + ('CSNU', + $$ + [ + { "id": 0, "seat": "3-4", "name": "Albânia", "code": "al" }, + { "id": 1, "seat": "2-5", "name": "Alemanha", "code": "de" }, + { "id": 2, "seat": "3-2", "name": "Brasil", "code": "br" } + ] + $$ + ); + +-- not so simple user insertion -- this was generated by gemini +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +DO $$ +DECLARE + -- 1. Definimos os UUIDs fixos e fáceis para usar em qualquer lugar do seed + id_chair uuid := '11111111-1111-1111-1111-111111111111'; + id_albania uuid := '22222222-2222-2222-2222-222222222222'; + id_alemanha uuid := '33333333-3333-3333-3333-333333333333'; + id_brazil uuid := '44444444-4444-4444-4444-444444444444'; +BEGIN + + -- ---------------------------------------------------- + -- INSERÇÃO DOS USUÁRIOS (AUTH) + -- ---------------------------------------------------- + + -- Usuário 1: chair + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_chair, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'chair@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "chair"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_chair, 'chair@codelab.usp.br', format('{"sub":"%s","email":"chair@codelab.usp.br"}', id_chair)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + + -- Usuário 2: albania + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_albania, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'albania@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "albania"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_albania, 'albania@codelab.usp.br', format('{"sub":"%s","email":"albania@codelab.usp.br"}', id_albania)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + + -- Usuário 3: alemanha + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_alemanha, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'alemanha@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_alemanha, 'alemanha@codelab.usp.br', format('{"sub":"%s","email":"alemanha@codelab.usp.br"}', id_alemanha)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_brazil, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'brazil@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_brazil, 'brazil@codelab.usp.br', format('{"sub":"%s","email":"brazil@codelab.usp.br"}', id_brazil)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + +END $$; + +-- we got back to a simple table creation +insert into public.session_assignments + (user_id, session_id, role, delegation_id) +values + ('11111111-1111-1111-1111-111111111111', 1, 'CHAIR', null), + ('22222222-2222-2222-2222-222222222222', 1, 'DELEGATE', 0), + ('33333333-3333-3333-3333-333333333333', 1, 'DELEGATE', 1), + ('44444444-4444-4444-4444-444444444444', 1, 'DELEGATE', 2); From a3288826b8d68c5fc70d59e7d6260bc27e2a944f Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:24:51 -0300 Subject: [PATCH 09/28] docs: add supabase workflow readme --- supabase/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/supabase/README.md b/supabase/README.md index 6040a98..d116c4a 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -10,3 +10,31 @@ Supabase is used for: - This gives us full raw SQL flexibility, with the caveat of managing security by ourselves. - Thus, we only use this direct connection on the backend, since it is expected to have privileges. - Auth. Frontend queries Supabase Auth directly and stores credentials, tokens, etc. Backend uses DB secrety key to locally verify if a key is valid, expired, has been tampered with, etc. + +## Workflow + +Start Supabase by issuing (on the project root directory): + +``` +supabase start +``` + +This will pull the necessary images and start their containers. This will output infos you might need, such as: +- Authentication Keys: save `Secret` to `SUPABASE_JWT_SECRET` on `.env` +- REST, DATABASE Url: save to `SUPABASE_URL` and `DATABASE_URL`, respectivelly + +Then, you can run existing migrations (or apply new ones) with: + +``` +supabase db reset +``` + +When you want to create/change a table, use (prefer to use a name that explicitly tells what you're doing): + +``` +supabase migration new +``` + +This will create a `.sql` file under `migrations/`. You can put SQL in there. Take a look at the docs for further references. + +If you don't want to use the terminal, fell free to use Supabase Studio by going to the **project url**. From bd2798a57d6c2d37f8d9e15e4f61bc2d7fd2187f Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:01:18 -0300 Subject: [PATCH 10/28] refactor: refactor db session lookup on views.py by adding a lifespan component to fastapi Co-authored-by: Codex --- backend/app/auth/service.py | 2 +- backend/app/core/database.py | 46 ++++++++++++++++++--------------- backend/app/main.py | 35 +++++++++++++++++++------ backend/app/session/views.py | 12 +++++---- backend/pyproject.toml | 3 ++- backend/uv.lock | 50 ++++++++++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 36 deletions(-) diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 12c2235..0947509 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -26,7 +26,7 @@ def verify_jwt_token( audience="authenticated", ) - return AuthUser(user_id=UUID(payload.get("id")), email=payload.get("email")) + return AuthUser(user_id=UUID(payload.get("sub")), email=payload.get("email")) except jwt.ExpiredSignatureError: raise InvalidTokenError("Token has expired") diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 6d991ac..3c4bee8 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1,29 +1,33 @@ from typing import AsyncGenerator -from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession -from sqlalchemy.orm import declarative_base -from app.core.config import get_settings +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + create_async_engine, + async_sessionmaker, + AsyncSession, +) +from app.core.config import Settings +from fastapi import Request -settings = get_settings() -# Gerado pelo Gemini -# Para o Supavisor em modo Transaction, desativamos o prepared statement cache do asyncpg -# passando o parâmetro 'prepared_statement_cache_size=0' na query string ou via connect_args. -engine = create_async_engine( - settings.DATABASE_URL.get_secret_value(), - pool_pre_ping=True, - connect_args={ - "prepared_statement_cache_size": 0 # Crucial para o Supavisor não quebrar! - }, -) +def create_db( + settings: Settings, +) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: -async_sessionmaker_factory = async_sessionmaker( - bind=engine, autocommit=False, autoflush=False, expire_on_commit=False -) + engine = create_async_engine( + settings.DATABASE_URL.get_secret_value(), + pool_pre_ping=True, + connect_args={"prepared_statement_cache_size": 0}, + ) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + return engine, session_factory -Base = declarative_base() +async def get_db_session( + request: Request, +) -> AsyncGenerator[AsyncSession, None]: + """Dependency Injection for DB session. Uses app state for storing db setup""" + session_factory = request.app.state.db_session_factory -# Dependência assíncrona pythônica para o Depends do FastAPI -async def get_db_session() -> AsyncGenerator[AsyncSession, None]: - async with async_sessionmaker_factory() as session: + async with session_factory as session: yield session diff --git a/backend/app/main.py b/backend/app/main.py index 837f887..8f92729 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,37 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.core.config import get_settings +from app.core.database import create_db from app.session.engine import SessionEngine from app.session.manager import ConnectionManager from app.session.views import router as session_router -app = FastAPI(title="WebMUN API") + +# Startup and shutdown logic for shared variables (db session, settings, connection manager, etc) +# You can view more of this on "FastAPI Lifespan" +@asynccontextmanager +async def lifespan(app: FastAPI): + # startup phase + settings = get_settings() + engine, session_factory = create_db(settings) + app.state.db_engine = engine + app.state.db_session_factory = session_factory + + app.state.session_engine = SessionEngine() + app.state.connection_manager = ConnectionManager() + + yield + + await engine.dispose() + + +app = FastAPI( + title="WebMUN API", + lifespan=lifespan, +) # CORS config for Vite app.add_middleware( @@ -15,12 +41,5 @@ allow_headers=["*"], ) -manager = ConnectionManager() -engine = SessionEngine() - -# lets manager and engine be long lived -app.state.connection_manager = manager -app.state.engine = engine - # include commitees here? app.include_router(session_router, prefix="/committees", tags=["committees"]) diff --git a/backend/app/session/views.py b/backend/app/session/views.py index bcfd0d9..f7077b1 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -70,7 +70,6 @@ async def websocket_endpoint( manager=Depends(get_connection_manager), engine=Depends(get_session_engine), logger=Depends(get_logger), - session: AsyncSession = Depends(get_db_session), settings: Settings = Depends(get_settings), ): await websocket.accept() @@ -79,10 +78,13 @@ async def websocket_endpoint( auth_user = verify_jwt_token( settings=settings, token=auth_message["access_token"] ) - - assignment = await access.resolve_committee_assignment( - session=session, user_id=auth_user.user_id, session_id=session_id - ) + + # short db scope for access lookup at first connection + session_factory = websocket.app.state.db_session_factory + async with session_factory() as db: + assignment = await access.resolve_committee_assignment( + session=db, user_id=auth_user.user_id, session_id=session_id + ) actor = service.build_actor( manager=manager, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index aedebfd..1d1f6d4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,7 +10,8 @@ dependencies = [ "ruff>=0.15.17", "supabase", "pyjwt", - "sqlalchemy" + "sqlalchemy", + "asyncpg" ] [tool.ruff] diff --git a/backend/uv.lock b/backend/uv.lock index 702a89b..3261f11 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -38,11 +38,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + [[package]] name = "backend" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "asyncpg" }, { name = "fastapi", extra = ["standard"] }, { name = "pyjwt" }, { name = "pytest" }, @@ -53,6 +102,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "asyncpg" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, { name = "pyjwt" }, { name = "pytest", specifier = ">=9.1.0" }, From 4da3e54cc40f7c19d4a4976f97933bbf83472898 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:27:53 -0300 Subject: [PATCH 11/28] refactor: adds (mostly) better error handling for WS and HTTP auth errors --- backend/app/auth/dep.py | 33 +++++++++++++++++++++++++++++++++ backend/app/auth/service.py | 22 ++++++++++++---------- backend/app/session/views.py | 29 ++++++++++++++++++++++++----- 3 files changed, 69 insertions(+), 15 deletions(-) create mode 100644 backend/app/auth/dep.py diff --git a/backend/app/auth/dep.py b/backend/app/auth/dep.py new file mode 100644 index 0000000..58fe05c --- /dev/null +++ b/backend/app/auth/dep.py @@ -0,0 +1,33 @@ +from fastapi import Depends, status +from fastapi.exceptions import HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from app.auth.service import AuthUser, TokenExpiredError, TokenInvalidError, verify_jwt_token +from typing import Annotated + +from app.core.config import Settings, get_settings + +bearer = HTTPBearer() + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer)], + settings: Annotated[Settings, Depends(get_settings)], +) -> AuthUser: + """Dependency injection to get current user""" + try: + return verify_jwt_token( + token=credentials.credentials, + settings=settings, + ) + except TokenExpiredError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token_expired", # TODO: map this out to an outer table, since both WS and HTTP uses this + headers={"WWW-Authenticate": "Bearer"}, + ) from e + except TokenInvalidError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="token_invalid", # TODO: map this out to an outer table, since both WS and HTTP uses this + headers={"WWW-Authenticate": "Bearer"}, + ) from e + diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index 0947509..d776c7d 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -3,16 +3,18 @@ from pydantic import BaseModel from app.core.config import Settings +# Self documented errors: might be better for protection +class TokenInvalidError(Exception): + pass + +class TokenExpiredError(Exception): + pass class AuthUser(BaseModel): user_id: UUID email: str | None -class InvalidTokenError(Exception): - pass - - def verify_jwt_token( token: str, # self contained token that already has info like id, email, etc settings: Settings, @@ -27,9 +29,9 @@ def verify_jwt_token( ) return AuthUser(user_id=UUID(payload.get("sub")), email=payload.get("email")) - - except jwt.ExpiredSignatureError: - raise InvalidTokenError("Token has expired") - - except jwt.InvalidTokenError: - raise InvalidTokenError("Token is invalid") + + except jwt.exceptions.ExpiredSignatureError: + raise TokenExpiredError("Access token expired") + + except jwt.exceptions.InvalidTokenError: + raise TokenInvalidError("Access token invalid") diff --git a/backend/app/session/views.py b/backend/app/session/views.py index f7077b1..e328be1 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -9,11 +9,11 @@ WebSocketDisconnect, status, ) -from jwt.exceptions import InvalidTokenError from sqlalchemy.ext.asyncio import AsyncSession from app.access.service import AccessDenied -from app.auth.service import verify_jwt_token +from app.auth.dep import get_current_user +from app.auth.service import verify_jwt_token, TokenInvalidError, TokenExpiredError from app.core.config import Settings from app.core.dep import get_connection_manager, get_logger, get_session_engine from app.core.config import get_settings @@ -50,7 +50,7 @@ async def create_session_endpoint( session_schema: SessionCreationSchema, session: AsyncSession = Depends(get_db_session), manager=Depends(get_connection_manager), - current_user=Depends(verify_jwt_token), + current_user=Depends(get_current_user), # TODO: map out that only admins can create sessions ): # Mock a session being created await service.create_session_service( @@ -72,9 +72,18 @@ async def websocket_endpoint( logger=Depends(get_logger), settings: Settings = Depends(get_settings), ): + """ + Endpoint for connecting to a committee session. + + Overall flow: accepts websocket -> user sends token -> we verify it -> + if valid, lookup an assigment -> builds actor, connects to manager, and receive The + current session state + """ + await websocket.accept() try: auth_message = await websocket.receive_json() + # we use pure verify_jwt_token due to websocket not handling bearer-header support auth_user = verify_jwt_token( settings=settings, token=auth_message["access_token"] ) @@ -109,6 +118,16 @@ async def websocket_endpoint( except WebSocketDisconnect: manager.disconnect(websocket, session_id) - except (InvalidTokenError, AccessDenied, service.ActorResolutionError): - await websocket.close(code=1008) + except (TokenExpiredError, TokenInvalidError, AccessDenied, service.ActorResolutionError) as exc: + if isinstance(exc, TokenExpiredError): + reason = "token_expired" + elif isinstance(exc, TokenInvalidError): + reason = "token_invalid" + elif isinstance(exc, AccessDenied): + reason = "access_denied" + else: + reason = "actor_resolution_error" + + await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason=reason) return + From 5e7b2d920973ba3de870d9f8be8f646393cd8f7b Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:36:08 -0300 Subject: [PATCH 12/28] refactor: formats some dependency injections to Annotated[..., Depends(..)] format --- backend/app/session/repository.py | 5 ----- backend/app/session/service.py | 3 --- backend/app/session/views.py | 23 ++++++++++++++--------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py index dc01e21..05311f2 100644 --- a/backend/app/session/repository.py +++ b/backend/app/session/repository.py @@ -5,11 +5,6 @@ from .models import DelegationContext -class SessionRepository: - def __init__(self, session: AsyncSession): - self.session = session - - async def create_session( session: AsyncSession, name: str, delegations: list[DelegationContext] ) -> int | None: diff --git a/backend/app/session/service.py b/backend/app/session/service.py index e1fec1d..bfa744b 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -4,7 +4,6 @@ import logging from datetime import datetime -from typing import Any from uuid import UUID from fastapi import HTTPException, status @@ -38,8 +37,6 @@ class SessionCreationError(Exception): pass -# SessionService class defined with Dependency Injection - def build_actor( manager: ConnectionManager, diff --git a/backend/app/session/views.py b/backend/app/session/views.py index e328be1..bbbb07d 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -1,6 +1,8 @@ # An adapter. Accepts HTTP/Websockets messages, validates envelopes, calls/attaches services and return/send errors # The 1st layer when connecting to clients +from typing import Annotated + from fastapi import ( APIRouter, Depends, @@ -13,17 +15,20 @@ from app.access.service import AccessDenied from app.auth.dep import get_current_user -from app.auth.service import verify_jwt_token, TokenInvalidError, TokenExpiredError +from app.auth.service import AuthUser, verify_jwt_token, TokenInvalidError, TokenExpiredError from app.core.config import Settings from app.core.dep import get_connection_manager, get_logger, get_session_engine from app.core.config import get_settings from app.core.database import get_db_session +from app.session.engine import SessionEngine from app.session.enums import ChairEvents, DelegateEvents, SessionRole +from app.session.manager import ConnectionManager from app.session.models import SessionLiveState from app.session.schemas import SessionCreationSchema, SessionEvent import app.access.service as access import app.session.service as service +import logging router = APIRouter() @@ -48,16 +53,16 @@ async def health(): @router.post("/", status_code=status.HTTP_204_NO_CONTENT) async def create_session_endpoint( session_schema: SessionCreationSchema, - session: AsyncSession = Depends(get_db_session), - manager=Depends(get_connection_manager), - current_user=Depends(get_current_user), # TODO: map out that only admins can create sessions + session: Annotated[AsyncSession, Depends(get_db_session)], + manager: Annotated[ConnectionManager, Depends(get_connection_manager)], + current_user: Annotated[AuthUser, Depends(get_current_user)], # TODO: map out that only admins can create sessions ): # Mock a session being created await service.create_session_service( manager=manager, session=session, session_schema=session_schema, - creator_uuid=current_user.id, + creator_uuid=current_user.user_id, ) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -67,10 +72,10 @@ async def create_session_endpoint( async def websocket_endpoint( websocket: WebSocket, session_id: int, - manager=Depends(get_connection_manager), - engine=Depends(get_session_engine), - logger=Depends(get_logger), - settings: Settings = Depends(get_settings), + manager: Annotated[ConnectionManager, Depends(get_connection_manager)], + engine: Annotated[SessionEngine, Depends(get_session_engine)], + logger: Annotated[logging.Logger, Depends(get_logger)], + settings: Annotated[Settings, Depends(get_settings)], ): """ Endpoint for connecting to a committee session. From 93b6b509cd19143637abb2fc456218a76c5981aa Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:58:20 -0300 Subject: [PATCH 13/28] feat: add initial database schemas --- supabase/config.toml | 2 +- .../20260719224327_create_initial_tables.sql | 74 ++++++++ .../20260719224327_create_sessions_table.sql | 19 -- supabase/seed.sql | 162 +++++++++++------- supabase/users.sql | 41 +++++ 5 files changed, 217 insertions(+), 81 deletions(-) create mode 100644 supabase/migrations/20260719224327_create_initial_tables.sql delete mode 100644 supabase/migrations/20260719224327_create_sessions_table.sql create mode 100644 supabase/users.sql diff --git a/supabase/config.toml b/supabase/config.toml index c09ed5a..7ba3e61 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -68,7 +68,7 @@ schema_paths = [] enabled = true # Specifies an ordered list of seed files to load during db reset. # Supports glob patterns relative to supabase directory: "./seeds/*.sql" -sql_paths = ["./seed.sql"] +sql_paths = ["./users.sql", "./seed.sql"] [db.network_restrictions] # Enable management of network restrictions. diff --git a/supabase/migrations/20260719224327_create_initial_tables.sql b/supabase/migrations/20260719224327_create_initial_tables.sql new file mode 100644 index 0000000..0de2132 --- /dev/null +++ b/supabase/migrations/20260719224327_create_initial_tables.sql @@ -0,0 +1,74 @@ +create type activity_status as enum ('active', 'closed', 'planned', 'cancelled'); +create type committee_role as enum ('chair', 'delegate'); +create type asset_type as enum ('preset', 'custom'); + +create table conferences ( + id bigint primary key generated always as identity, + name varchar(255) not null, + status activity_status not null default 'planned', + owner_id uuid not null references auth.users on delete cascade +); + +create table committees ( + id bigint primary key generated always as identity, + conference_id bigint not null references conferences(id), + name varchar(64) not null, + status activity_status not null default 'planned' +); + +-- This table references preset/custom representations that we might work with. We can perhaps separate the two of them later +create table representations ( + id bigint primary key generated always as identity, + name varchar(64), + rep_type asset_type not null default 'preset', + code varchar(10), + identifier varchar(255) not null, -- this can be either an url if the rep_type is custom, or a code (like 'br') + conference_id bigint null -- if set references a specific conference. if we delete that conference entry, we might setup a custom handler on the + -- backend +); + +-- preset layouts for conferences +create table layouts ( + id bigint primary key generated always as identity, + name varchar(64), + conference_id bigint null references conferences(id) on delete cascade, + -- if conference_id is null, its a preset. else, its an overall layout for a conference + committee_id bigint null references committees(id) on delete cascade -- we can also customize for a committee +); + +-- present layout seats for conferences +create table layout_seats ( + layout_id bigint not null references layouts(id) on delete cascade, + representation_id bigint not null references representations(id) on delete cascade, + seat_label varchar(3), + + primary key (layout_id, representation_id) +); + +-- real committee seats. We'll use a copy-on-create strategy (thx codex) to populate this +create table committee_seats ( + committee_id bigint not null references committees(id) on delete cascade, + representation_id bigint not null references representations(id) on delete cascade, + seat_label varchar(3), + + primary key (committee_id, representation_id) +); + +create table committee_assignments ( + user_id uuid not null references auth.users(id) on delete cascade, + committee_id bigint not null references committees(id) on delete cascade, + role committee_role not null, + representation_id bigint null references representations(id), + + primary key (user_id, committee_id) +); + +create table sessions ( + id bigint primary key generated always as identity, + committee_id bigint not null references committees(id) on delete cascade, + status activity_status not null default 'planned', + started_at timestamptz, + ended_at timestamptz, + state_snapshot JSONB +); + diff --git a/supabase/migrations/20260719224327_create_sessions_table.sql b/supabase/migrations/20260719224327_create_sessions_table.sql deleted file mode 100644 index 18409ba..0000000 --- a/supabase/migrations/20260719224327_create_sessions_table.sql +++ /dev/null @@ -1,19 +0,0 @@ -create type session_status as enum ('Open', 'Closed'); -create type session_role as enum ('CHAIR', 'DELEGATE'); - -create table sessions ( - id bigint primary key generated always as identity, - name varchar(255), - created_at timestamptz default now(), - status session_status not null default 'Open', - delegations_config JSONB NOT NULL DEFAULT '[]'::jsonb -); - -create table session_assignments ( - user_id uuid not null references auth.users on delete cascade, - session_id bigint not null references sessions(id) on delete cascade, - role session_role not null, - delegation_id int null, - - primary key (user_id, session_id) -); diff --git a/supabase/seed.sql b/supabase/seed.sql index 8dd62ef..9b27847 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,63 +1,103 @@ --- simple session insertion -insert into public.sessions - (name, delegations_config) +-- default conference + +insert into public.conferences + (name, status, owner_id) +values + ('I WebMUN', 'active', '11111111-1111-1111-1111-111111111111'); + +insert into public.committees + (conference_id, name, status) +values + ((select id from conferences where name = 'I WebMUN'), 'CSNU', 'planned'); + +insert into public.representations (name, code, identifier) values + ('Albânia', 'al', 'al'), + ('Alemanha', 'de', 'de'), + ('Austrália', 'au', 'au'), + ('Brasil', 'br', 'br'), + ('China', 'cn', 'cn'), + ('Coreia do Sul', 'kr', 'kr'), + ('Emirados Árabes', 'ae', 'ae'), + ('Estados Unidos', 'us', 'us'), + ('Filipinas', 'ph', 'ph'), + ('França', 'fr', 'fr'), + ('Guatemala', 'gt', 'gt'), + ('Hong Kong', 'hk', 'hk'), + ('Índia', 'in', 'in'), + ('Indonésia', 'id', 'id'), + ('Japão', 'jp', 'jp'), + ('Malásia', 'my', 'my'), + ('Reino Unido', 'gb', 'gb'), + ('Rússia', 'ru', 'ru'), + ('Suíça', 'ch', 'ch'), + ('Taiwan', 'tw', 'tw'), + ('Turquia', 'tr', 'tr'); + +insert into layouts + (name, conference_id, committee_id) values - ('CSNU', - $$ - [ - { "id": 0, "seat": "3-4", "name": "Albânia", "code": "al" }, - { "id": 1, "seat": "2-5", "name": "Alemanha", "code": "de" }, - { "id": 2, "seat": "3-2", "name": "Brasil", "code": "br" } - ] - $$ - ); - --- not so simple user insertion -- this was generated by gemini -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - -DO $$ -DECLARE - -- 1. Definimos os UUIDs fixos e fáceis para usar em qualquer lugar do seed - id_chair uuid := '11111111-1111-1111-1111-111111111111'; - id_albania uuid := '22222222-2222-2222-2222-222222222222'; - id_alemanha uuid := '33333333-3333-3333-3333-333333333333'; - id_brazil uuid := '44444444-4444-4444-4444-444444444444'; -BEGIN - - -- ---------------------------------------------------- - -- INSERÇÃO DOS USUÁRIOS (AUTH) - -- ---------------------------------------------------- - - -- Usuário 1: chair - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_chair, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'chair@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "chair"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; - INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) - VALUES (gen_random_uuid(), id_chair, 'chair@codelab.usp.br', format('{"sub":"%s","email":"chair@codelab.usp.br"}', id_chair)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; - - -- Usuário 2: albania - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_albania, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'albania@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "albania"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; - INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) - VALUES (gen_random_uuid(), id_albania, 'albania@codelab.usp.br', format('{"sub":"%s","email":"albania@codelab.usp.br"}', id_albania)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; - - -- Usuário 3: alemanha - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_alemanha, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'alemanha@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; - INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) - VALUES (gen_random_uuid(), id_alemanha, 'alemanha@codelab.usp.br', format('{"sub":"%s","email":"alemanha@codelab.usp.br"}', id_alemanha)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; - - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_brazil, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'brazil@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; - INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) - VALUES (gen_random_uuid(), id_brazil, 'brazil@codelab.usp.br', format('{"sub":"%s","email":"brazil@codelab.usp.br"}', id_brazil)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; - -END $$; - --- we got back to a simple table creation -insert into public.session_assignments - (user_id, session_id, role, delegation_id) + ('Standard 21 Room', null, null); + +insert into layout_seats + (layout_id, representation_id, seat_label) values - ('11111111-1111-1111-1111-111111111111', 1, 'CHAIR', null), - ('22222222-2222-2222-2222-222222222222', 1, 'DELEGATE', 0), - ('33333333-3333-3333-3333-333333333333', 1, 'DELEGATE', 1), - ('44444444-4444-4444-4444-444444444444', 1, 'DELEGATE', 2); + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'al'), '3-4'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'de'), '2-5'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'au'), '2-4'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'br'), '3-2'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'cn'), '1-6'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'kr'), '3-5'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ae'), '3-9'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'us'), '1-2'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ph'), '2-2'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'fr'), '1-4'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'gt'), '3-7'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'hk'), '3-1'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'in'), '3-6'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'id'), '3-3'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'jp'), '2-1'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'my'), '2-3'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'gb'), '1-3'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ru'), '1-5'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ch'), '3-8'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'tw'), '1-1'), + ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'tr'), '2-6'); + +insert into committee_seats + (committee_id, representation_id, seat_label) +values + (1, (select id from representations where code = 'al'), '3-4'), + (1, (select id from representations where code = 'de'), '2-5'), + (1, (select id from representations where code = 'au'), '2-4'), + (1, (select id from representations where code = 'br'), '3-2'), + (1, (select id from representations where code = 'cn'), '1-6'), + (1, (select id from representations where code = 'kr'), '3-5'), + (1, (select id from representations where code = 'ae'), '3-9'), + (1, (select id from representations where code = 'us'), '1-2'), + (1, (select id from representations where code = 'ph'), '2-2'), + (1, (select id from representations where code = 'fr'), '1-4'), + (1, (select id from representations where code = 'gt'), '3-7'), + (1, (select id from representations where code = 'hk'), '3-1'), + (1, (select id from representations where code = 'in'), '3-6'), + (1, (select id from representations where code = 'id'), '3-3'), + (1, (select id from representations where code = 'jp'), '2-1'), + (1, (select id from representations where code = 'my'), '2-3'), + (1, (select id from representations where code = 'gb'), '1-3'), + (1, (select id from representations where code = 'ru'), '1-5'), + (1, (select id from representations where code = 'ch'), '3-8'), + (1, (select id from representations where code = 'tw'), '1-1'), + (1, (select id from representations where code = 'tr'), '2-6'); + +insert into public.committee_assignments + (user_id, committee_id, role, representation_id) +values + ('11111111-1111-1111-1111-111111111111', 1, 'chair', null), + ('22222222-2222-2222-2222-222222222222', 1, 'delegate', (select id from representations where code = 'al')), + ('33333333-3333-3333-3333-333333333333', 1, 'delegate', (select id from representations where code = 'de')), + ('44444444-4444-4444-4444-444444444444', 1, 'delegate', (select id from representations where code = 'br')); + +insert into public.sessions + (committee_id) +values + (1); + diff --git a/supabase/users.sql b/supabase/users.sql new file mode 100644 index 0000000..fa00aef --- /dev/null +++ b/supabase/users.sql @@ -0,0 +1,41 @@ +-- not so simple user insertion -- this was generated by gemini +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +DO $$ +DECLARE + -- 1. Definimos os UUIDs fixos e fáceis para usar em qualquer lugar do seed + id_chair uuid := '11111111-1111-1111-1111-111111111111'; + id_albania uuid := '22222222-2222-2222-2222-222222222222'; + id_alemanha uuid := '33333333-3333-3333-3333-333333333333'; + id_brazil uuid := '44444444-4444-4444-4444-444444444444'; +BEGIN + + -- ---------------------------------------------------- + -- INSERÇÃO DOS USUÁRIOS (AUTH) + -- ---------------------------------------------------- + + -- Usuário 1: chair + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_chair, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'chair@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "chair"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_chair, 'chair@codelab.usp.br', format('{"sub":"%s","email":"chair@codelab.usp.br"}', id_chair)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + + -- Usuário 2: albania + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_albania, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'albania@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "albania"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_albania, 'albania@codelab.usp.br', format('{"sub":"%s","email":"albania@codelab.usp.br"}', id_albania)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + + -- Usuário 3: alemanha + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_alemanha, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'alemanha@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_alemanha, 'alemanha@codelab.usp.br', format('{"sub":"%s","email":"alemanha@codelab.usp.br"}', id_alemanha)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) + VALUES (id_brazil, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'brazil@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) + VALUES (gen_random_uuid(), id_brazil, 'brazil@codelab.usp.br', format('{"sub":"%s","email":"brazil@codelab.usp.br"}', id_brazil)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; + +END $$; + From c01193752e874012729f561a5fa940191b715be3 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:54:46 -0300 Subject: [PATCH 14/28] feat(session): add committee-scoped session access Co-authored-by: Codex --- backend/app/access/models.py | 7 +- backend/app/access/repository.py | 26 ++-- backend/app/access/service.py | 30 +++- backend/app/core/database.py | 2 +- backend/app/session/models.py | 14 +- backend/app/session/repository.py | 131 ++++++++++++++---- backend/app/session/schemas.py | 15 +- backend/app/session/service.py | 107 ++++++++++++-- backend/app/session/views.py | 96 +++++++++++-- .../20260719224327_create_initial_tables.sql | 1 + 10 files changed, 342 insertions(+), 87 deletions(-) diff --git a/backend/app/access/models.py b/backend/app/access/models.py index 12c9dd3..88a1090 100644 --- a/backend/app/access/models.py +++ b/backend/app/access/models.py @@ -6,8 +6,7 @@ @dataclass(frozen=True) class CommitteeAssignment: """Object that holds info about an UUID to a commitee and Delegation / Chair""" - user_id: UUID - session_id: int # TODO: remove this to map out to committees/conferences - role: Literal["CHAIR", "DELEGATE"] - delegation_id: int | None + committee_id: int # TODO: remove this to map out to committees/conferences + role: Literal["chair", "delegate"] + representation_id: int | None diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py index b8963dc..b9f3d40 100644 --- a/backend/app/access/repository.py +++ b/backend/app/access/repository.py @@ -4,23 +4,25 @@ from sqlalchemy.ext.asyncio import AsyncSession from .models import CommitteeAssignment - +# TODO: pass this to a conference/ domain async def get_committee_assignment( - session: AsyncSession, user_id: UUID, session_id: int + session: AsyncSession, user_id: UUID, committee_id: int ) -> CommitteeAssignment | None: query = text(""" SELECT - sa.user_id, - sa.role, - sa.delegation_id - FROM public.sessions s - JOIN public.session_assignments sa - WHERE s.id = :session_id AND - sa.user_id = :user_id + ca.user_id, + ca.committee_id, + ca.role, + ca.representation_id + FROM public.committees c + JOIN public.committee_assignments ca + ON c.id = ca.committee_id + WHERE c.id = :committee_id AND + ca.user_id = :user_id """) result = await session.execute( - query, {"session_id": session_id, "user_id": user_id} + query, {"committee_id": committee_id, "user_id": user_id} ) row = result.mappings().one_or_none() @@ -29,7 +31,7 @@ async def get_committee_assignment( return CommitteeAssignment( user_id=row["user_id"], - session_id=session_id, + committee_id=row["committee_id"], role=row["role"], - delegation_id=row["delegation_id"], + representation_id=row["representation_id"], ) diff --git a/backend/app/access/service.py b/backend/app/access/service.py index 2c3ab80..b87d12c 100644 --- a/backend/app/access/service.py +++ b/backend/app/access/service.py @@ -1,8 +1,8 @@ +from typing import Literal from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession from app.access.models import CommitteeAssignment -from app.session.enums import SessionRole from .repository import get_committee_assignment @@ -12,14 +12,36 @@ class AccessDenied(Exception): ... async def resolve_committee_assignment( session: AsyncSession, user_id: UUID, - session_id: int, + committee_id: int, ) -> CommitteeAssignment: - assignment = await get_committee_assignment(session, user_id, session_id) + assignment: CommitteeAssignment | None = await get_committee_assignment( + session, user_id, committee_id + ) if assignment is None: raise AccessDenied("User has no committee assignment") - if assignment.role == SessionRole.DELEGATE and assignment.delegation_id is None: + if assignment.role == 'delegate' and assignment.representation_id is None: raise AccessDenied("Delegate role has no delegation id") return assignment + + +async def verify_user_role( + session: AsyncSession, + user_id: UUID, + committee_id: int, + required_role: Literal["chair", "delegate"], +) -> CommitteeAssignment: + """Require a user's role within one specific committee.""" + assignment = await get_committee_assignment(session, user_id, committee_id) + + if assignment is None: + raise AccessDenied("User has no committee assignment") + + if assignment.role != required_role: + raise AccessDenied( + f"User requires the {required_role} role for this committee" + ) + + return assignment diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 3c4bee8..33c8f47 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -29,5 +29,5 @@ async def get_db_session( """Dependency Injection for DB session. Uses app state for storing db setup""" session_factory = request.app.state.db_session_factory - async with session_factory as session: + async with session_factory() as session: yield session diff --git a/backend/app/session/models.py b/backend/app/session/models.py index c2989e1..7a50478 100644 --- a/backend/app/session/models.py +++ b/backend/app/session/models.py @@ -1,13 +1,25 @@ # This file defines internal models not used as schemas for the application # Even though it's internal, some things may be sent out to public (TODO:like SessionLiveState) +from dataclasses import dataclass from datetime import datetime from typing import Literal +from uuid import UUID from pydantic import BaseModel import app.session.enums as enums - +@dataclass(frozen=True) +class StoredSession: + id: int + committee_id: int + name: str | None + status: str + started_at: datetime + ended_at: datetime + state_snapshot: dict | None + +# TODO: separate this into a DelegationContext with a name/user_id or profile and SeatContext for a map class DelegationContext(BaseModel): id: int name: str diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py index 05311f2..a8dda27 100644 --- a/backend/app/session/repository.py +++ b/backend/app/session/repository.py @@ -1,31 +1,33 @@ +from sqlalchemy.exc import SQLAlchemyError + from app.access.models import CommitteeAssignment -from app.session.schemas import DelegationSchema from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession -from .models import DelegationContext +from app.session.models import DelegationContext, StoredSession + +class RepositoryError(Exception): + """Base exception for all repository issues""" + pass async def create_session( - session: AsyncSession, name: str, delegations: list[DelegationContext] + session: AsyncSession, + committee_id: int, + name: str | None, ) -> int | None: - """Creates a session on db""" + """Creates a planned session on repository""" query = text(""" INSERT INTO public.sessions - (name, delegations_config) + (committee_id, name) VALUES - (:name, :delegations_list) + (:committee_id, :name) RETURNING id """) result = await session.execute( query, - { - "name": name, - "delegations_list": [ - delegation.model_dump(mode="json") for delegation in delegations - ], - }, + { "name": name, "committee_id": committee_id } ) row = result.mappings().one_or_none() @@ -34,24 +36,64 @@ async def create_session( return row["id"] - -async def bulk_get_uuids_by_email( - session: AsyncSession, delegations_schema: list[DelegationSchema] -): - """Searches an uuid via email on supabase auth""" - - # selects all (id, email) rows mapping out emails +async def get_session_info( + session: AsyncSession, + committee_session_id: int +) -> StoredSession | None: + """Gets info for a session""" query = text(""" - SELECT email, id - FROM auth.users - WHERE email = ANY(:emails) + SELECT * FROM public.sessions s + WHERE s.id = :committee_session_id """) result = await session.execute( - query, {"emails": [d.user_email for d in delegations_schema]} + query, + {"committee_session_id": committee_session_id}, ) - return {r["email"]: r["id"] for r in result.mappings().all()} + row = result.mappings().one_or_none() + if row is None: + return None + + return StoredSession( + id=row["id"], + committee_id=row["committee_id"], + name=row["name"], + status=row["status"], + started_at=row["started_at"], + ended_at=row["ended_at"], + state_snapshot=row["state_snapshot"] + ) + +async def update_session_info( + session: AsyncSession, + session_info: StoredSession, +) -> None: + query = text(""" + UPDATE public.sessions + SET + name = :name, + status = :status, + started_at = :started_at, + ended_at = :ended_at, + state_snapshot = :state_snapshot + WHERE id = :committee_session_id + """) + + try: + await session.execute( + query, + { + "name": session_info.name, + "status": session_info.status, + "started_at": session_info.started_at, + "ended_at": session_info.ended_at, + "state_snapshot": session_info.state_snapshot, + "committee_session_id": session_info.id + } + ) + except SQLAlchemyError: + raise RepositoryError("Session update failed") async def bulk_insert_assignments( @@ -69,7 +111,7 @@ async def bulk_insert_assignments( ] query = text(""" - INSERT INTO public.session_assignments (user_id, session_id, role, delegation_id) + INSERT INTO public.committee_assignments (user_id, committee_id, role, representation_id) VALUES (:user_id, :session_id, :role, :delegation_id) """) @@ -77,3 +119,42 @@ async def bulk_insert_assignments( query, params, ) + +# TODO: pass this out to conferences/ domain +async def bulk_get_delegation_context( + session: AsyncSession, + committee_id: int, +) -> list[DelegationContext] | None: + """ + Get by bulk a list of representations to insert in the live state + Note: this returns a list of DelegationContext. This is not ideal. + We need a clear separation between delegation name/code and the map. + Since it's not what we have for now, we'll keep this here + """ + + representation_query = text(""" + SELECT r.id, r.name, r.code, c.seat_label as seat + FROM public.representations r + JOIN public.committee_seats c + ON r.id = c.representation_id + WHERE c.committee_id = :committee_id + """) + + + result = await session.execute( + representation_query, + {"committee_id": committee_id} + ) + + if result is None: + return None + + return [DelegationContext( + id=r["id"], + name=r["name"], + seat=r["seat"], + code=r["code"]) + for r in result.mappings().all()] + + + diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index 1d78db5..8fee631 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -1,22 +1,13 @@ from typing import Annotated, Literal - -from pydantic import BaseModel, Field, EmailStr - +from pydantic import BaseModel, Field import app.session.enums as enums # --- General Schemas --- - -class DelegationSchema(BaseModel): - user_email: EmailStr # TODO: enable multiple users per delegation - name: str - seat: str # TODO: check if its better to use an int here on the frontend - code: str # TODO: check if this is needed - - class SessionCreationSchema(BaseModel): + """Schema to create a session. Follows a DB schema + extra configs format""" + committee_id: int name: str | None = None - delegations: list[DelegationSchema] # useful for now # --- Delegate Payloads --- diff --git a/backend/app/session/service.py b/backend/app/session/service.py index bfa744b..d0dc98e 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -2,22 +2,20 @@ # The 2nd layer between the API route and inner things such as database, FSM engine, Redis, etc. Should orchestrate everything # Also calls the manager in order to broadcast states, etc +from dataclasses import replace import logging from datetime import datetime from uuid import UUID from fastapi import HTTPException, status -from pydantic import TypeAdapter +from pydantic import EmailStr, TypeAdapter +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.access.models import CommitteeAssignment import app.session.enums as enums -from app.session.repository import ( - bulk_get_uuids_by_email, - bulk_insert_assignments, - create_session, -) import app.session.schemas as schemas +import app.session.repository as repository from app.session.engine import SessionEngine from .manager import ConnectionManager @@ -26,6 +24,7 @@ RollCallContext, SessionActor, SessionLiveState, + StoredSession, ) @@ -36,6 +35,11 @@ class ActorResolutionError(Exception): class SessionCreationError(Exception): pass +class SessionFetchError(Exception): + pass + +class SessionUpdateError(Exception): + pass def build_actor( @@ -73,24 +77,100 @@ def build_actor( display_name=delegation.name, ) - async def create_session_service( session: AsyncSession, + session_schema: schemas.SessionCreationSchema, +) -> int: + """Create a planned session""" + session_id = await repository.create_session( + session=session, committee_id=session_schema.committee_id, name=session_schema.name + ) + + if session_id is None: + raise SessionCreationError("Could not create session with given schema") + + await session.commit() + + return session_id + + +async def activate_session( + session: AsyncSession, + manager: ConnectionManager, + committee_session_id: int, +): + """Activate a planned session""" + stored = await repository.get_session_info( + session=session, committee_session_id=committee_session_id + ) + + if stored is None: + raise SessionFetchError("Could not fetch session info") + if stored.status != 'planned': + raise SessionFetchError("Session already started") + + delegations = await repository.bulk_get_delegation_context( + session=session, committee_id=stored.committee_id + ) + + if delegations is None: + raise SessionFetchError("Could not fetch session delegations info") + + live_state = SessionLiveState( + session_id=stored.id, + start_time=datetime.now(), + delegations=delegations, + current_state=enums.States.SETUP, + gsl_default_time_seconds=60, + roll_call=RollCallContext(registry={}), + voting_choice={}, + ) + + updated = replace( + stored, + status='active', + started_at=datetime.now(), + state_snapshot=live_state.model_dump(mode='json') + ) + + try: + await repository.update_session_info( + session=session, session_info=updated + ) + except repository.RepositoryError: + raise SessionUpdateError("Could not update session info") + + await session.commit() + + manager.room_states[committee_session_id] = live_state + manager.active_connections.setdefault(committee_session_id, {}) + +async def pause_session( + session: AsyncSession, manager: ConnectionManager, + committee_session_id: int, +) -> None: + pass + +async def close_session( + session: AsyncSession, + manager: ConnectionManager, + committee_session_id: int, +) -> None: + pass + +""" +async def old_create_session_service( + session: AsyncSession, session_schema: schemas.SessionCreationSchema, creator_uuid: UUID, ): - # loop through delegationSchema and convert each to DelegationContext - delegations = [ - # id now begins at 0 instead of 1 - DelegationContext(id=i, name=d.name, seat=d.seat, code=d.code) - for i, d in enumerate(session_schema.delegations) - ] # insert into database and return the session id here session_id = await create_session( session=session, name=session_schema.name or "", delegations=delegations ) + if session_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -137,6 +217,7 @@ async def create_session_service( voting_choice={}, ) manager.active_connections.setdefault(session_id, {}) +""" async def handle_client_messages( diff --git a/backend/app/session/views.py b/backend/app/session/views.py index bbbb07d..6feb9f9 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -11,6 +11,7 @@ WebSocketDisconnect, status, ) +from fastapi.exceptions import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.access.service import AccessDenied @@ -27,6 +28,7 @@ from app.session.schemas import SessionCreationSchema, SessionEvent import app.access.service as access +import app.session.repository as repository import app.session.service as service import logging @@ -41,37 +43,102 @@ async def dummy( enum1: DelegateEvents, enum2: ChairEvents, ): + """Dummy workaround to make FastAPI add all schemas to the OpenAPI file""" return Response(status_code=status.HTTP_404_NOT_FOUND) @router.get("/health", status_code=status.HTTP_200_OK) async def health(): + """Healthcheck route""" return Response(status_code=status.HTTP_200_OK) -# Create committee route, receives a contentbody following CommitteeCreationSchema's format -@router.post("/", status_code=status.HTTP_204_NO_CONTENT) +@router.post("/", status_code=status.HTTP_200_OK) async def create_session_endpoint( session_schema: SessionCreationSchema, session: Annotated[AsyncSession, Depends(get_db_session)], - manager: Annotated[ConnectionManager, Depends(get_connection_manager)], current_user: Annotated[AuthUser, Depends(get_current_user)], # TODO: map out that only admins can create sessions ): - # Mock a session being created - await service.create_session_service( - manager=manager, - session=session, - session_schema=session_schema, - creator_uuid=current_user.user_id, - ) + """POST endpoint to create a new session""" + try: + await access.verify_user_role( + session=session, + user_id=current_user.user_id, + committee_id=session_schema.committee_id, + required_role="chair", + ) + + res = await service.create_session_service( + session=session, + session_schema=session_schema, + ) + return {"id": res, "status": "Created"} + + except AccessDenied as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(exc), + ) + except service.SessionCreationError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + + +@router.post("/{session_id}/activate", status_code=status.HTTP_204_NO_CONTENT) +async def activate_session_endpoint( + session_id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + manager: Annotated[ConnectionManager, Depends(get_connection_manager)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): + """Endpoint to activate a planned session""" + try: + stored = await repository.get_session_info( + session=db_session, + committee_session_id=session_id, + ) + if stored is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session not found", + ) + + await access.verify_user_role( + session=db_session, + user_id=current_user.user_id, + committee_id=stored.committee_id, + required_role="chair", + ) + + await service.activate_session( + session=db_session, + manager=manager, + committee_session_id=session_id) + except AccessDenied as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(exc), + ) + except service.SessionFetchError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) + except service.SessionUpdateError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(exc) + ) - return Response(status_code=status.HTTP_204_NO_CONTENT) @router.websocket("/ws/{session_id}") async def websocket_endpoint( websocket: WebSocket, session_id: int, + committee_id: int, manager: Annotated[ConnectionManager, Depends(get_connection_manager)], engine: Annotated[SessionEngine, Depends(get_session_engine)], logger: Annotated[logging.Logger, Depends(get_logger)], @@ -97,14 +164,14 @@ async def websocket_endpoint( session_factory = websocket.app.state.db_session_factory async with session_factory() as db: assignment = await access.resolve_committee_assignment( - session=db, user_id=auth_user.user_id, session_id=session_id + session=db, user_id=auth_user.user_id, committee_id=committee_id ) actor = service.build_actor( manager=manager, session_id=session_id, - role=SessionRole(assignment.role), - delegation_id=assignment.delegation_id, + role=SessionRole(assignment.role.upper()), + delegation_id=assignment.representation_id, ) await manager.connect(websocket, session_id, actor) @@ -135,4 +202,3 @@ async def websocket_endpoint( await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason=reason) return - diff --git a/supabase/migrations/20260719224327_create_initial_tables.sql b/supabase/migrations/20260719224327_create_initial_tables.sql index 0de2132..ce34ead 100644 --- a/supabase/migrations/20260719224327_create_initial_tables.sql +++ b/supabase/migrations/20260719224327_create_initial_tables.sql @@ -66,6 +66,7 @@ create table committee_assignments ( create table sessions ( id bigint primary key generated always as identity, committee_id bigint not null references committees(id) on delete cascade, + name varchar(64), status activity_status not null default 'planned', started_at timestamptz, ended_at timestamptz, From 6fcc0c8127d2149678d0b0c38755140164f2bab2 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:57:13 -0300 Subject: [PATCH 15/28] test: add authentication and session coverage Co-authored-by: Codex --- backend/app/access/tests/test_access.py | 1 - backend/app/tests/access/__init__.py | 1 + backend/app/tests/access/test_service.py | 95 +++++++++++++++++++++ backend/app/tests/auth/__init__.py | 1 + backend/app/tests/auth/test_service.py | 65 ++++++++++++++ backend/app/tests/conftest.py | 16 +--- backend/app/tests/session/__init__.py | 1 + backend/app/tests/session/test_engine.py | 1 - backend/app/tests/session/test_service.py | 81 +++++++----------- backend/app/tests/session/test_views.py | 93 -------------------- backend/app/tests/session/test_websocket.py | 7 +- 11 files changed, 198 insertions(+), 164 deletions(-) delete mode 100644 backend/app/access/tests/test_access.py create mode 100644 backend/app/tests/access/__init__.py create mode 100644 backend/app/tests/access/test_service.py create mode 100644 backend/app/tests/auth/__init__.py create mode 100644 backend/app/tests/auth/test_service.py create mode 100644 backend/app/tests/session/__init__.py delete mode 100644 backend/app/tests/session/test_views.py diff --git a/backend/app/access/tests/test_access.py b/backend/app/access/tests/test_access.py deleted file mode 100644 index 5871ed8..0000000 --- a/backend/app/access/tests/test_access.py +++ /dev/null @@ -1 +0,0 @@ -import pytest diff --git a/backend/app/tests/access/__init__.py b/backend/app/tests/access/__init__.py new file mode 100644 index 0000000..a92842e --- /dev/null +++ b/backend/app/tests/access/__init__.py @@ -0,0 +1 @@ +"""Access-control tests.""" diff --git a/backend/app/tests/access/test_service.py b/backend/app/tests/access/test_service.py new file mode 100644 index 0000000..6f9077a --- /dev/null +++ b/backend/app/tests/access/test_service.py @@ -0,0 +1,95 @@ +from uuid import uuid4 + +import pytest + +from app.access.models import CommitteeAssignment +from app.access.service import ( + AccessDenied, + resolve_committee_assignment, + verify_user_role, +) + + +@pytest.mark.anyio +async def test_denies_user_without_assignment(monkeypatch): + async def no_assignment(*_args, **_kwargs): + return None + + monkeypatch.setattr("app.access.service.get_committee_assignment", no_assignment) + + with pytest.raises(AccessDenied, match="no committee assignment"): + await resolve_committee_assignment(object(), uuid4(), 1) + + +@pytest.mark.anyio +async def test_denies_delegate_without_delegation(monkeypatch): + async def invalid_assignment(*_args, **_kwargs): + return CommitteeAssignment( + user_id=uuid4(), + committee_id=1, + role="delegate", + representation_id=None, + ) + + monkeypatch.setattr( + "app.access.service.get_committee_assignment", invalid_assignment + ) + + with pytest.raises(AccessDenied, match="no delegation id"): + await resolve_committee_assignment(object(), uuid4(), 1) + + +@pytest.mark.anyio +async def test_returns_valid_assignment(monkeypatch): + assignment = CommitteeAssignment( + user_id=uuid4(), + committee_id=1, + role="chair", + representation_id=None, + ) + + async def valid_assignment(*_args, **_kwargs): + return assignment + + monkeypatch.setattr("app.access.service.get_committee_assignment", valid_assignment) + + result = await resolve_committee_assignment(object(), assignment.user_id, 1) + + assert result is assignment + + +@pytest.mark.anyio +async def test_role_check_denies_a_delegate_when_a_chair_is_required(monkeypatch): + assignment = CommitteeAssignment( + user_id=uuid4(), + committee_id=1, + role="delegate", + representation_id=3, + ) + + async def delegate_assignment(*_args, **_kwargs): + return assignment + + monkeypatch.setattr( + "app.access.service.get_committee_assignment", delegate_assignment + ) + + with pytest.raises(AccessDenied, match="requires the chair role"): + await verify_user_role(object(), assignment.user_id, 1, "chair") + + +@pytest.mark.anyio +async def test_role_check_returns_matching_assignment(monkeypatch): + assignment = CommitteeAssignment( + user_id=uuid4(), + committee_id=1, + role="chair", + representation_id=None, + ) + + async def chair_assignment(*_args, **_kwargs): + return assignment + + monkeypatch.setattr("app.access.service.get_committee_assignment", chair_assignment) + + assert await verify_user_role(object(), assignment.user_id, 1, "chair") is assignment diff --git a/backend/app/tests/auth/__init__.py b/backend/app/tests/auth/__init__.py new file mode 100644 index 0000000..44b5911 --- /dev/null +++ b/backend/app/tests/auth/__init__.py @@ -0,0 +1 @@ +"""Authentication tests.""" diff --git a/backend/app/tests/auth/test_service.py b/backend/app/tests/auth/test_service.py new file mode 100644 index 0000000..3081b56 --- /dev/null +++ b/backend/app/tests/auth/test_service.py @@ -0,0 +1,65 @@ +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from uuid import uuid4 + +import jwt +import pytest + +from app.auth.service import TokenExpiredError, TokenInvalidError, verify_jwt_token + + +class FakeSecret: + def __init__(self, value: str): + self.value = value + + def get_secret_value(self) -> str: + return self.value + + +@pytest.fixture +def settings(): + return SimpleNamespace( + SUPABASE_JWT_SECRET=FakeSecret("test-secret-that-is-at-least-32-bytes"), + JWT_ALGORITHM="HS256", + ) + + +def make_token(*, user_id, expires_at: datetime) -> str: + return jwt.encode( + { + "sub": str(user_id), + "email": "delegate@example.test", + "aud": "authenticated", + "exp": expires_at, + }, + "test-secret-that-is-at-least-32-bytes", + algorithm="HS256", + ) + + +def test_verifies_valid_supabase_style_token(settings): + user_id = uuid4() + token = make_token( + user_id=user_id, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + + user = verify_jwt_token(token, settings) + + assert user.user_id == user_id + assert user.email == "delegate@example.test" + + +def test_rejects_expired_token(settings): + token = make_token( + user_id=uuid4(), + expires_at=datetime.now(UTC) - timedelta(minutes=1), + ) + + with pytest.raises(TokenExpiredError): + verify_jwt_token(token, settings) + + +def test_rejects_invalid_token(settings): + with pytest.raises(TokenInvalidError): + verify_jwt_token("not-a-jwt", settings) diff --git a/backend/app/tests/conftest.py b/backend/app/tests/conftest.py index 3408b4a..7577ec0 100644 --- a/backend/app/tests/conftest.py +++ b/backend/app/tests/conftest.py @@ -3,9 +3,6 @@ from datetime import datetime import pytest -from fastapi.testclient import TestClient - -from app.main import app from app.session.engine import SessionEngine from app.session.enums import States from app.session.manager import ConnectionManager @@ -18,12 +15,6 @@ from app.session.enums import ( SessionRole, ) -from app.session.service import SessionService - - -@pytest.fixture -def client(): - return TestClient(app) @pytest.fixture @@ -84,8 +75,5 @@ def fake_engine(): @pytest.fixture -def service(fake_engine): - return SessionService( - manager=ConnectionManager(), - engine=fake_engine, - ) +def connection_manager(): + return ConnectionManager() diff --git a/backend/app/tests/session/__init__.py b/backend/app/tests/session/__init__.py new file mode 100644 index 0000000..76c49ae --- /dev/null +++ b/backend/app/tests/session/__init__.py @@ -0,0 +1 @@ +"""Session tests.""" diff --git a/backend/app/tests/session/test_engine.py b/backend/app/tests/session/test_engine.py index 8098c7e..9eb78f6 100644 --- a/backend/app/tests/session/test_engine.py +++ b/backend/app/tests/session/test_engine.py @@ -698,7 +698,6 @@ def test_chair_insert_queue_uses_delegation_id( assert state.gsl_queue == [0] -@pytest.mark.xfail(strict=True, reason="OpenSessionEvent handler is not implemented.") def test_chair_open_session_starts_roll_call( engine: eng.SessionEngine, session_state: md.SessionLiveState, diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index 4ab50da..ebc91aa 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -4,31 +4,20 @@ import pytest from app.session import enums -from app.session.models import SessionActor, SessionLiveState, SessionRole -from app.session.schemas import DelegationSchema, SessionCreationSchema -from app.session.service import ActorResolutionError, SessionService - - -@pytest.fixture -def delegation_schema_list(): - brazil = DelegationSchema(seat="1-2", name="Brazil", code="br") - usa = DelegationSchema(seat="3-4", name="USA", code="us") - russia = DelegationSchema(seat="5-6", name="Russia", code="ru") - return [brazil, usa, russia] - - -@pytest.fixture -def session_test_schema(delegation_schema_list: list[DelegationSchema]): - return SessionCreationSchema(session_id=0, delegations=delegation_schema_list) +from app.session.manager import ConnectionManager +from app.session.models import SessionActor, SessionLiveState +from app.session.enums import SessionRole +from app.session.service import ActorResolutionError, build_actor, handle_client_messages def test_can_build_actor( - service: SessionService, + connection_manager: ConnectionManager, session_state: SessionLiveState, ) -> None: - service.manager.room_states[0] = session_state + connection_manager.room_states[0] = session_state - actor = service.build_actor( + actor = build_actor( + manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, delegation_id=0, @@ -36,15 +25,16 @@ def test_can_build_actor( assert actor.role == SessionRole.DELEGATE assert actor.delegation is not None - assert actor.delegation.id == service.manager.room_states[0].delegations[0].id - assert actor.delegation.name == service.manager.room_states[0].delegations[0].name + assert actor.delegation.id == connection_manager.room_states[0].delegations[0].id + assert actor.delegation.name == connection_manager.room_states[0].delegations[0].name def test_cannot_build_actor_with_nonexistent_state( - service: SessionService, + connection_manager: ConnectionManager, ) -> None: with pytest.raises(ActorResolutionError, match="session not found"): - service.build_actor( + build_actor( + manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, delegation_id=0, @@ -52,51 +42,41 @@ def test_cannot_build_actor_with_nonexistent_state( def test_cannot_build_actor_with_no_delegation_id( - service: SessionService, + connection_manager: ConnectionManager, session_state: SessionLiveState, ) -> None: with pytest.raises(ActorResolutionError, match="needs delegate id"): - service.manager.room_states[0] = session_state - service.build_actor( + connection_manager.room_states[0] = session_state + build_actor( + manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, ) def test_cannot_build_actor_with_nonexistent_delegation( - service: SessionService, + connection_manager: ConnectionManager, session_state: SessionLiveState, ) -> None: with pytest.raises(ActorResolutionError, match="delegation not found"): - service.manager.room_states[0] = session_state + connection_manager.room_states[0] = session_state - service.build_actor( + build_actor( + manager=connection_manager, session_id=0, role=SessionRole.DELEGATE, delegation_id=999, ) - -def test_can_create_session( - service: SessionService, - session_test_schema: SessionCreationSchema, -) -> None: - service.create_session(session_test_schema) - - assert len(service.manager.room_states) == 1 - assert len(service.manager.room_states[0].delegations) == 3 - assert service.manager.room_states[0].delegations[0].name == "Brazil" - assert service.manager.room_states[0].delegations[1].id == 1 - - @pytest.mark.anyio async def test_handle_client_messages_dispatches_and_broadcasts( - service: SessionService, + connection_manager: ConnectionManager, + fake_engine, session_state: SessionLiveState, delegate_actor: SessionActor, ) -> None: session_state.current_state = enums.States.OPEN_GSL - service.manager.room_states[session_state.session_id] = session_state + connection_manager.room_states[session_state.session_id] = session_state broadcasts = [] @@ -104,7 +84,7 @@ async def fake_broadcast_state(session_id: int): broadcasts.append(session_id) # replaces real broadcast state with this one - service.manager.broadcast_state = fake_broadcast_state + connection_manager.broadcast_state = fake_broadcast_state data = json.dumps( { @@ -113,13 +93,16 @@ async def fake_broadcast_state(session_id: int): } ) - await service.handle_client_messages( + await handle_client_messages( + manager=connection_manager, + engine=fake_engine, + logger=__import__("logging").getLogger("test"), session_id=session_state.session_id, actor=delegate_actor, data=data ) # tests if engine dispatched the message - assert service.engine.dispatched["state"] is session_state # type: ignore - assert service.engine.dispatched["event"].type == enums.DelegateEvents.JOIN_QUEUE # type: ignore - assert service.engine.dispatched["actor"] is delegate_actor # type: ignore + assert fake_engine.dispatched["state"] is session_state + assert fake_engine.dispatched["event"].type == enums.DelegateEvents.JOIN_QUEUE + assert fake_engine.dispatched["actor"] is delegate_actor assert broadcasts == [session_state.session_id] diff --git a/backend/app/tests/session/test_views.py b/backend/app/tests/session/test_views.py deleted file mode 100644 index a6f3ff3..0000000 --- a/backend/app/tests/session/test_views.py +++ /dev/null @@ -1,93 +0,0 @@ -import pytest -from fastapi.testclient import TestClient -from starlette.websockets import WebSocketDisconnect - -from app.main import session_service # service used by our test client / main.py -from app.session import enums -from app.session.models import DelegationContext - - -# defines a clean service state after each testing -@pytest.fixture(autouse=True) -def clear_service_state(): - session_service.manager.room_states.clear() - session_service.manager.active_connections.clear() - - -def session_payload(delegations: list[DelegationContext]) -> dict: - return { - "session_id": 0, - "name": "Test Session", - "delegations": [ - delegation.model_dump(mode="json") for delegation in delegations - ], - } - - -def test_create_session_endpoint_returns_204_and_stores_state( - client: TestClient, - delegation_list: list[DelegationContext], -) -> None: - response = client.post("/committees/", json=session_payload(delegation_list)) - - assert response.status_code == 204 - assert ( - session_service.manager.room_states[0].current_state == enums.States.ROLL_CALL - ) - assert session_service.manager.room_states[0].delegations == delegation_list - assert session_service.manager.active_connections[0] == {} - - -def test_create_session_endpoint_rejects_invalid_body( - client: TestClient, -) -> None: - response = client.post("/committees/", json={"session_id": 0}) - - assert response.status_code == 422 - assert session_service.manager.room_states == {} - - -def test_websocket_chair_connect_receives_state_snapshot( - client: TestClient, - delegation_list: list[DelegationContext], -) -> None: - client.post("/committees/", json=session_payload(delegation_list)) - - with client.websocket_connect( - "/committees/ws/0?role=CHAIR&display_name=Chair" - ) as websocket: - data = websocket.receive_json() - - assert data["session_id"] == 0 - assert data["current_state"] == enums.States.ROLL_CALL.value - assert data["delegations"][0]["name"] == "Brazil" - - -def test_websocket_delegate_connect_receives_state_snapshot( - client: TestClient, - delegation_list: list[DelegationContext], -) -> None: - client.post("/committees/", json=session_payload(delegation_list)) - - with client.websocket_connect( - "/committees/ws/0?role=DELEGATE&delegation_id=1" - ) as websocket: - data = websocket.receive_json() - - assert data["session_id"] == 0 - assert data["current_state"] == enums.States.ROLL_CALL.value - - -def test_websocket_delegate_connect_rejects_unknown_delegation( - client: TestClient, - delegation_list: list[DelegationContext], -) -> None: - client.post("/committees/", json=session_payload(delegation_list)) - - with pytest.raises(WebSocketDisconnect) as exc_info: - with client.websocket_connect( - "/committees/ws/0?role=DELEGATE&delegation_id=999" - ): - pass - - assert exc_info.value.code == 1008 diff --git a/backend/app/tests/session/test_websocket.py b/backend/app/tests/session/test_websocket.py index de1f3dc..27f199d 100644 --- a/backend/app/tests/session/test_websocket.py +++ b/backend/app/tests/session/test_websocket.py @@ -6,12 +6,8 @@ class FakeWebSocket: def __init__(self) -> None: - self.accepted = False self.sent_json: list[dict] = [] - async def accept(self) -> None: - self.accepted = True - async def send_json(self, data: dict) -> None: self.sent_json.append(data) @@ -27,7 +23,7 @@ def connection_manager() -> ConnectionManager: @pytest.mark.anyio -async def test_connect_accepts_socket_and_stores_actor( +async def test_connect_stores_actor( connection_manager: ConnectionManager, chair_actor: SessionActor, ) -> None: @@ -35,7 +31,6 @@ async def test_connect_accepts_socket_and_stores_actor( await connection_manager.connect(websocket, session_id=1, actor=chair_actor) - assert websocket.accepted is True assert connection_manager.active_connections[1][websocket] == chair_actor assert connection_manager.get_actor(websocket, 1) == chair_actor assert connection_manager.count_connected(1) == 1 From bd57b659a5a1a63a2649d5698ef1b2305fbac033 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:12:57 -0300 Subject: [PATCH 16/28] fix: override vulnerable js-yaml dependency --- frontend/package-lock.json | 22 ---------------------- frontend/package.json | 3 +++ 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c4bc94c..73195f0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -984,28 +984,6 @@ "url": "https://github.com/sponsors/hey-api" } }, - "node_modules/@hey-api/json-schema-ref-parser/node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@hey-api/openapi-ts": { "version": "0.99.0", "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.99.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b561733..56e8738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,5 +47,8 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", "vite": "^8.0.4" + }, + "overrides": { + "js-yaml": "4.3.0" } } From 83dc369ebeff4d92533915d9787944780d2711fc Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:14:00 -0300 Subject: [PATCH 17/28] fix(supabase): seed password-login users Co-authored-by: Codex --- supabase/users.sql | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/supabase/users.sql b/supabase/users.sql index fa00aef..1190ccb 100644 --- a/supabase/users.sql +++ b/supabase/users.sql @@ -15,27 +15,26 @@ BEGIN -- ---------------------------------------------------- -- Usuário 1: chair - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_chair, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'chair@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "chair"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change) + VALUES (id_chair, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'chair@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "chair"}'::jsonb, now(), now(), '', '', '', '') ON CONFLICT (id) DO NOTHING; INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) VALUES (gen_random_uuid(), id_chair, 'chair@codelab.usp.br', format('{"sub":"%s","email":"chair@codelab.usp.br"}', id_chair)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; -- Usuário 2: albania - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_albania, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'albania@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "albania"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change) + VALUES (id_albania, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'albania@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "albania"}'::jsonb, now(), now(), '', '', '', '') ON CONFLICT (id) DO NOTHING; INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) VALUES (gen_random_uuid(), id_albania, 'albania@codelab.usp.br', format('{"sub":"%s","email":"albania@codelab.usp.br"}', id_albania)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; -- Usuário 3: alemanha - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_alemanha, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'alemanha@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change) + VALUES (id_alemanha, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'alemanha@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now(), '', '', '', '') ON CONFLICT (id) DO NOTHING; INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) VALUES (gen_random_uuid(), id_alemanha, 'alemanha@codelab.usp.br', format('{"sub":"%s","email":"alemanha@codelab.usp.br"}', id_alemanha)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; - INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at) - VALUES (id_brazil, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'brazil@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now()) ON CONFLICT (id) DO NOTHING; + INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change) + VALUES (id_brazil, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'brazil@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now(), '', '', '', '') ON CONFLICT (id) DO NOTHING; INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) VALUES (gen_random_uuid(), id_brazil, 'brazil@codelab.usp.br', format('{"sub":"%s","email":"brazil@codelab.usp.br"}', id_brazil)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; END $$; - From 77bbcc883bf81d1f03d1cf651a30277bfe247325 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:14:52 -0300 Subject: [PATCH 18/28] feat(auth): verify Supabase JWKS tokens Co-authored-by: Codex --- .env.example | 2 +- backend/app/access/repository.py | 32 +++++++++++++++++++++++++++++ backend/app/access/service.py | 19 ++++++++++++++++- backend/app/access/views.py | 35 ++++++++++++++++++++++++++++++++ backend/app/auth/service.py | 34 ++++++++++++++++++++++++------- backend/app/core/config.py | 6 +++++- backend/app/main.py | 2 ++ backend/app/session/views.py | 9 ++++---- supabase/README.md | 2 +- 9 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 backend/app/access/views.py diff --git a/.env.example b/.env.example index b628822..973fdcc 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Settings/authService SUPABASE_URL=http_url_to_supabase +SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key_here SUPABASE_JWT_SECRET=jwt_secret here JWT_ALGORITHM=HS256 DATABASE_URL=direct_url_to_supabase - diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py index b9f3d40..72c083b 100644 --- a/backend/app/access/repository.py +++ b/backend/app/access/repository.py @@ -35,3 +35,35 @@ async def get_committee_assignment( role=row["role"], representation_id=row["representation_id"], ) + + +async def get_session_assignment( + session: AsyncSession, user_id: UUID, session_id: int +) -> CommitteeAssignment | None: + """Get a user's assignment for the committee that owns a session.""" + query = text(""" + SELECT + ca.user_id, + ca.committee_id, + ca.role, + ca.representation_id + FROM public.sessions s + JOIN public.committee_assignments ca + ON ca.committee_id = s.committee_id + WHERE s.id = :session_id + AND ca.user_id = :user_id + """) + + result = await session.execute( + query, {"session_id": session_id, "user_id": user_id} + ) + row = result.mappings().one_or_none() + if row is None: + return None + + return CommitteeAssignment( + user_id=row["user_id"], + committee_id=row["committee_id"], + role=row["role"], + representation_id=row["representation_id"], + ) diff --git a/backend/app/access/service.py b/backend/app/access/service.py index b87d12c..0b4514a 100644 --- a/backend/app/access/service.py +++ b/backend/app/access/service.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.access.models import CommitteeAssignment -from .repository import get_committee_assignment +from .repository import get_committee_assignment, get_session_assignment class AccessDenied(Exception): ... @@ -27,6 +27,23 @@ async def resolve_committee_assignment( return assignment +async def resolve_session_assignment( + session: AsyncSession, + user_id: UUID, + session_id: int, +) -> CommitteeAssignment: + """Resolve the assignment for a session without trusting client committee data.""" + assignment = await get_session_assignment(session, user_id, session_id) + + if assignment is None: + raise AccessDenied("User has no assignment for this session") + + if assignment.role == "delegate" and assignment.representation_id is None: + raise AccessDenied("Delegate role has no delegation id") + + return assignment + + async def verify_user_role( session: AsyncSession, user_id: UUID, diff --git a/backend/app/access/views.py b/backend/app/access/views.py new file mode 100644 index 0000000..2e33290 --- /dev/null +++ b/backend/app/access/views.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dep import get_current_user +from app.auth.service import AuthUser +from app.core.database import get_db_session + +from .service import AccessDenied, resolve_session_assignment + +router = APIRouter() + + +@router.get("/sessions/{session_id}/me") +async def get_my_session_access( + session_id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +): + """Return the authenticated user's actor context for a session.""" + try: + assignment = await resolve_session_assignment( + db_session, current_user.user_id, session_id + ) + except AccessDenied as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=str(exc), + ) + + return { + "role": assignment.role, + "representation_id": assignment.representation_id, + } diff --git a/backend/app/auth/service.py b/backend/app/auth/service.py index d776c7d..fc3b89d 100644 --- a/backend/app/auth/service.py +++ b/backend/app/auth/service.py @@ -1,4 +1,5 @@ import jwt +from functools import lru_cache from uuid import UUID from pydantic import BaseModel from app.core.config import Settings @@ -15,23 +16,42 @@ class AuthUser(BaseModel): email: str | None +@lru_cache +def get_jwk_client(supabase_url: str) -> jwt.PyJWKClient: + """Cache Supabase's public signing keys for asymmetric access tokens.""" + return jwt.PyJWKClient( + f"{supabase_url.rstrip('/')}/auth/v1/.well-known/jwks.json" + ) + + def verify_jwt_token( token: str, # self contained token that already has info like id, email, etc settings: Settings, ) -> AuthUser: - """Local Verification of user's JWT + Supabase JWT Secret""" + """Verify either legacy HS256 or current Supabase ES256 access tokens.""" try: + algorithm = jwt.get_unverified_header(token).get("alg") + + if algorithm == "HS256": + key = settings.SUPABASE_JWT_SECRET.get_secret_value() + elif algorithm == "ES256": + key = get_jwk_client(str(settings.SUPABASE_URL)).get_signing_key_from_jwt( + token + ).key + else: + raise TokenInvalidError("Unsupported access token algorithm") + payload = jwt.decode( token, - settings.SUPABASE_JWT_SECRET.get_secret_value(), - settings.JWT_ALGORITHM, + key, + algorithms=[algorithm], audience="authenticated", ) - return AuthUser(user_id=UUID(payload.get("sub")), email=payload.get("email")) - + return AuthUser(user_id=UUID(payload["sub"]), email=payload.get("email")) + except jwt.exceptions.ExpiredSignatureError: raise TokenExpiredError("Access token expired") - - except jwt.exceptions.InvalidTokenError: + + except (jwt.exceptions.PyJWTError, KeyError, TypeError, ValueError): raise TokenInvalidError("Access token invalid") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d10fc6c..d26dc1b 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,4 +1,5 @@ from functools import lru_cache +from pathlib import Path from pydantic import AnyHttpUrl, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -17,7 +18,10 @@ class Settings(BaseSettings): JWT_ALGORITHM: str = "HS256" # pydantic config to read .env files - model_config = SettingsConfigDict(env_file="../../../.env", extra="ignore") + model_config = SettingsConfigDict( + env_file=Path(__file__).resolve().parents[3] / ".env", + extra="ignore", + ) @lru_cache diff --git a/backend/app/main.py b/backend/app/main.py index 8f92729..7fec850 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ from app.core.config import get_settings from app.core.database import create_db +from app.access.views import router as access_router from app.session.engine import SessionEngine from app.session.manager import ConnectionManager from app.session.views import router as session_router @@ -43,3 +44,4 @@ async def lifespan(app: FastAPI): # include commitees here? app.include_router(session_router, prefix="/committees", tags=["committees"]) +app.include_router(access_router, prefix="/access", tags=["access"]) diff --git a/backend/app/session/views.py b/backend/app/session/views.py index 6feb9f9..a6274dc 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -138,7 +138,6 @@ async def activate_session_endpoint( async def websocket_endpoint( websocket: WebSocket, session_id: int, - committee_id: int, manager: Annotated[ConnectionManager, Depends(get_connection_manager)], engine: Annotated[SessionEngine, Depends(get_session_engine)], logger: Annotated[logging.Logger, Depends(get_logger)], @@ -160,11 +159,11 @@ async def websocket_endpoint( settings=settings, token=auth_message["access_token"] ) - # short db scope for access lookup at first connection + # The session determines its committee; never accept it from the client. session_factory = websocket.app.state.db_session_factory - async with session_factory() as db: - assignment = await access.resolve_committee_assignment( - session=db, user_id=auth_user.user_id, committee_id=committee_id + async with session_factory() as db: + assignment = await access.resolve_session_assignment( + session=db, user_id=auth_user.user_id, session_id=session_id ) actor = service.build_actor( diff --git a/supabase/README.md b/supabase/README.md index d116c4a..cda16ea 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -20,8 +20,8 @@ supabase start ``` This will pull the necessary images and start their containers. This will output infos you might need, such as: -- Authentication Keys: save `Secret` to `SUPABASE_JWT_SECRET` on `.env` - REST, DATABASE Url: save to `SUPABASE_URL` and `DATABASE_URL`, respectivelly +- Authentication Keys: you might need to run `supabase status -o env`, and get the `PUBLISHABLE_KEY` for frontend use. For backend, we cache JWKS keys. JWT_SECRET is only needed when using legacy HS256 tokens (check the docs to see how to get this key - but we won't use that) Then, you can run existing migrations (or apply new ones) with: From 5f7f646e5b4e0c119b96ec26331b39ccafdaad7b Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:15:32 -0300 Subject: [PATCH 19/28] feat(frontend): add Supabase session authentication Co-authored-by: Codex --- docker-compose.yml | 4 +- frontend/.env.example | 6 +- frontend/README.md | 5 +- frontend/package-lock.json | 96 ++++++++++++++++++++- frontend/package.json | 1 + frontend/src/App.tsx | 18 +++- frontend/src/components/auth/login-form.tsx | 65 ++++++++++++-- frontend/src/context/AuthContext.tsx | 60 +++++++++++++ frontend/src/lib/api.ts | 21 +++++ frontend/src/lib/supabaseClient.ts | 17 ++++ frontend/src/main.tsx | 9 +- frontend/src/pages/Login_page.tsx | 4 +- frontend/src/pages/Session.tsx | 33 +++++-- 13 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 frontend/src/context/AuthContext.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/supabaseClient.ts diff --git a/docker-compose.yml b/docker-compose.yml index 737fd9d..32757f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,9 @@ services: - packages:/app/node_modules environment: - VITE_API_URL=http://localhost:8000 - - VITE_WS_URL=ws://localhost:8000/ws + - VITE_WS_URL=ws://localhost:8000/committees + - VITE_SUPABASE_URL=${SUPABASE_URL} + - VITE_SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY} depends_on: backend: condition: service_healthy diff --git a/frontend/.env.example b/frontend/.env.example index 4348ee7..fad3328 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,3 +1,7 @@ # Env variables used to communicate with backend VITE_API_URL=http://localhost:8000 -VITE_WS_URL=ws://localhost:8000/ws +VITE_WS_URL=ws://localhost:8000/committees + +# Supabase auth variables +VITE_SUPABASE_URL= +VITE_SUPABASE_PUBLISHABLE_KEY= diff --git a/frontend/README.md b/frontend/README.md index 6cfcbc3..3d1aabd 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -18,6 +18,7 @@ cp .env.example .env npm run dev ``` -# Credits +# Supabase Auth -Gemini AI, for setting up and insight on the tools used here +We use Supabase's SDK in order to connect to the Auth API and login users. You can check more on the official docs. +I you want to know how to develop with Supabase Auth, this [guide](https://supabase.com/docs/guides/getting-started/tutorials/with-react?queryGroups=database-method&database-method=dashboard) might be good. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 73195f0..50b54df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@base-ui/react": "^1.5.0", "@fontsource-variable/geist": "^5.2.8", "@hey-api/openapi-ts": "^0.99.0", + "@supabase/supabase-js": "^2.110.8", "@tailwindcss/vite": "^4.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -43,7 +44,7 @@ "vite": "^8.0.4" }, "engines": { - "node": ">= 20.0.0" + "node": ">= 22.0.0" } }, "node_modules/@babel/code-frame": { @@ -3111,6 +3112,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@supabase/auth-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz", + "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz", + "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz", + "integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz", + "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz", + "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz", + "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.8", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz", + "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.8", + "@supabase/functions-js": "2.110.8", + "@supabase/postgrest-js": "2.110.8", + "@supabase/realtime-js": "2.110.8", + "@supabase/storage-js": "2.110.8" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -5710,6 +5795,15 @@ "node": ">=18.18.0" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 56e8738..70b639a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@base-ui/react": "^1.5.0", "@fontsource-variable/geist": "^5.2.8", "@hey-api/openapi-ts": "^0.99.0", + "@supabase/supabase-js": "^2.110.8", "@tailwindcss/vite": "^4.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 77af776..097b0fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,31 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import type { ReactNode } from 'react'; +import { useAuth } from '@/context/AuthContext'; import SessionPage from './pages/Session'; import LoginPage from './pages/Login_page'; import Home from './pages/Home'; import CreateCommittee from './pages/CreateCommittee'; import UserDash from './pages/UserDashboard'; +function RequireAuth({ children }: { children: ReactNode }) { + const { loading, token } = useAuth(); + + if (loading) return

Loading session…

; + if (!token) return ; + + return children; +} + function App() { // We'll use react-router in order to develop our SPA return ( } /> - } /> + } + /> } /> } /> } /> diff --git a/frontend/src/components/auth/login-form.tsx b/frontend/src/components/auth/login-form.tsx index eafbe50..98e7e99 100644 --- a/frontend/src/components/auth/login-form.tsx +++ b/frontend/src/components/auth/login-form.tsx @@ -1,3 +1,5 @@ +import { useState } from "react" +import { useNavigate } from "react-router-dom" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { @@ -8,13 +10,49 @@ import { FieldSeparator, } from "@/components/ui/field" import { Input } from "@/components/ui/input" +import { supabase } from "@/lib/supabaseClient" export function LoginForm({ className, ...props }: React.ComponentProps<"form">) { + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError(null) + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + if (error) { + console.error("Supabase sign-in failed", error) + setError(error.message || "Unable to sign in. Please try again.") + setLoading(false) + return + } + + if (data.session) { + // Supabase automatically saved the session token in localStorage. + // Navigate straight to the user dashboard. + navigate("/dashboard") + } + } + return ( -
+

Login to your account

@@ -22,16 +60,26 @@ export function LoginForm({ Enter your email below to login to your account

+ + {error && ( +
+ {error} +
+ )} + Email setEmail(e.target.value)} required className="bg-background" /> +
Password @@ -45,19 +93,26 @@ export function LoginForm({ setPassword(e.target.value)} required className="bg-background" /> + - + + Or continue with + - - + Don't have an account?{" "} Sign up diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..e7133be --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useContext, useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import type { Session, User } from '@supabase/supabase-js'; +import { supabase } from '../lib/supabaseClient'; + +interface AuthContextType { + user: User | null; + session: Session | null; + token: string | null; + loading: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext({ + user: null, + session: null, + token: null, + loading: true, + signOut: async () => {}, +}); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // 1. Get initial session from localStorage on app load + supabase.auth.getSession().then(({ data: { session } }) => { + setSession(session); + setUser(session?.user ?? null); + setLoading(false); + }); + + // 2. Listen for auth changes (Login, Logout, Token Refresh) + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setSession(session); + setUser(session?.user ?? null); + setLoading(false); + }); + + return () => subscription.unsubscribe(); + }, []); + + const signOut = async () => { + await supabase.auth.signOut(); + }; + + const value = { + user, + session, + token: session?.access_token ?? null, // This is the JWT token to send to FastAPI + loading, + signOut, + }; + + return {children}; +} + +export const useAuth = () => useContext(AuthContext); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..1cc9fda --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,21 @@ +import { supabase } from "@/lib/supabaseClient" + + export async function apiFetch( + path: string, + init: RequestInit = {}, + ): Promise { + const { data: { session } } = await supabase.auth.getSession() + + if (!session) { + throw new Error("Authentication required") + } + + return fetch(`${import.meta.env.VITE_API_URL}${path}`, { + ...init, + headers: { + ...init.headers, + Authorization: `Bearer ${session.access_token}`, + "Content-Type": "application/json", + }, + }) + } diff --git a/frontend/src/lib/supabaseClient.ts b/frontend/src/lib/supabaseClient.ts new file mode 100644 index 0000000..077595e --- /dev/null +++ b/frontend/src/lib/supabaseClient.ts @@ -0,0 +1,17 @@ +/** + * lib/supabaseClient.js + * Helper to initialize the Supabase client. + */ + +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY + +export const supabase = createClient(supabaseUrl, supabaseAnonKey, { + auth: { + persistSession: true, // Automatically saves JWT & Refresh Token to localStorage + autoRefreshToken: true, // Automatically refreshes token when it expires + detectSessionInUrl: true, + }, +}); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b805a8f..0bd6e73 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' import { TooltipProvider } from "@/components/ui/tooltip" +import { AuthProvider } from '@/context/AuthContext' createRoot(document.getElementById('root')!).render( - - - + + + + + , ) diff --git a/frontend/src/pages/Login_page.tsx b/frontend/src/pages/Login_page.tsx index 202c3b4..e18e5bc 100644 --- a/frontend/src/pages/Login_page.tsx +++ b/frontend/src/pages/Login_page.tsx @@ -1,5 +1,7 @@ import { LoginForm } from "@/components/auth/login-form" -export default function LoginPage() { + +export default function Auth() { + return (
diff --git a/frontend/src/pages/Session.tsx b/frontend/src/pages/Session.tsx index 2788535..e9f0ef4 100644 --- a/frontend/src/pages/Session.tsx +++ b/frontend/src/pages/Session.tsx @@ -1,5 +1,6 @@ import { useEffect, useState} from 'react'; -import { useParams } from 'react-router-dom'; +import { Navigate, useParams } from 'react-router-dom'; +import { useAuth } from '@/context/AuthContext'; import { UpdateStore, useCommitteeStore } from '../store/useCommitteeStore.ts' import MotionsList from "@/components/session/motions-list" import SpeakerList from "@/components/session/speaker-list" @@ -31,6 +32,8 @@ export function sendMessage(data: any) { export default function SessionPage() { + const { loading, token } = useAuth() + const debateType = useCommitteeStore((state) => state.debate?.debate_type) const motions = [ @@ -61,7 +64,8 @@ export default function SessionPage() { ] // id that matches the name given in the Route path, at App.tsx - const { committeeId } = useParams<{ committeeId: string }>(); + const { sessionId } = useParams<{ sessionId: string }>(); + const parsedSessionId = Number(sessionId); //const {start_time} = useCommitteeStore(); const all = useCommitteeStore(); @@ -69,13 +73,18 @@ export default function SessionPage() { //const [, setUptime] = useState(0); useEffect(() => { - // Initialize WebSocket - socket = new WebSocket(`ws://localhost:8000/committees/ws/0?role=CHAIR&display_name=Chair`); + if (!token || !Number.isInteger(parsedSessionId) || parsedSessionId < 1) { + return; + } + + socket = new WebSocket( + `${import.meta.env.VITE_WS_URL}/ws/${parsedSessionId}`, + ); - socket.onopen = () => - { - setStatus("Connected"); - } + socket.onopen = () => { + socket?.send(JSON.stringify({ access_token: token })); + setStatus("Connected"); + } socket.onmessage = (event) => { const data = JSON.parse(event.data); @@ -86,7 +95,13 @@ export default function SessionPage() { socket.onclose = () => setStatus("Disconnected"); return () => socket?.close(); // Cleanup on unmount - }, [committeeId]); + }, [parsedSessionId, token]); + + if (loading) return

Loading session…

; + if (!token) return ; + if (!Number.isInteger(parsedSessionId) || parsedSessionId < 1) { + return

Invalid session ID.

; + } // Useeffect for local uptime timer /*useEffect(() => { From 5c9fce718e5fc78cea713d01ecc57d7ea4a821a2 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:16:35 -0300 Subject: [PATCH 20/28] docs: update README --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 877fe4f..ec4963e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ -# webmun +# WebMUN -Projeto do USP CodeLab para simulações de seções da ONU \ No newline at end of file +USPCodeLab project for managing and hosting real time Model United Nations + +# Prerequisites + +This project stack is composed of: +- React 19 + Vite 8 as the frontend stack +- Tailwind, Shadcn as UI libraries +- Python 3.11, FastAPI, pytest and ruff +- Supabase for database and auth +- Redis for in-memory database + +Further improvements are planned to make this a cloud-native application. From 424dd3b9da7d2759d9e2321c5c7fc2c0413b8671 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:40:35 -0300 Subject: [PATCH 21/28] chore: add Makefile and some fixes for .env on docker --- Makefile | 13 +++++++++++++ backend/.env.example | 4 ++++ backend/app/core/config.py | 8 ++------ backend/app/core/database.py | 7 ++++++- docker-compose.yml | 15 ++++++++------- 5 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 Makefile create mode 100644 backend/.env.example diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eabe82a --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: dev stop + +dev: + echo "Starting dev environment" + supabase start || npx supabase start + docker compose up --build + +stop: + echo "Stopping dev environment" + docker compose down + supabase stop || npx supabase stop + + diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..8dc002a --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +SUPABASE_URL=http://127.0.0.1:54321 +# Required only while supporting legacy HS256 Supabase tokens. +SUPABASE_JWT_SECRET=jwt_secret_here +DATABASE_URL=postgresql+asyncpg://postgres:postgres@127.0.0.1:54322/postgres diff --git a/backend/app/core/config.py b/backend/app/core/config.py index d26dc1b..28536ac 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,4 @@ from functools import lru_cache -from pathlib import Path from pydantic import AnyHttpUrl, SecretStr from pydantic_settings import BaseSettings, SettingsConfigDict @@ -17,11 +16,8 @@ class Settings(BaseSettings): SUPABASE_JWT_SECRET: SecretStr JWT_ALGORITHM: str = "HS256" - # pydantic config to read .env files - model_config = SettingsConfigDict( - env_file=Path(__file__).resolve().parents[3] / ".env", - extra="ignore", - ) + # Host development uses backend/.env; containers and cloud inject process env. + model_config = SettingsConfigDict(env_file=".env", extra="ignore") @lru_cache diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 33c8f47..381ff6e 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -12,9 +12,14 @@ def create_db( settings: Settings, ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + database_url = settings.DATABASE_URL.get_secret_value() + if database_url.startswith("postgresql://"): + database_url = database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) engine = create_async_engine( - settings.DATABASE_URL.get_secret_value(), + database_url, pool_pre_ping=True, connect_args={"prepared_statement_cache_size": 0}, ) diff --git a/docker-compose.yml b/docker-compose.yml index 32757f4..b8f9b16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,14 +6,11 @@ services: user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" ports: - "5173:5173" + env_file: + - ./frontend/.env.local volumes: - ./frontend:/app - packages:/app/node_modules - environment: - - VITE_API_URL=http://localhost:8000 - - VITE_WS_URL=ws://localhost:8000/committees - - VITE_SUPABASE_URL=${SUPABASE_URL} - - VITE_SUPABASE_PUBLISHABLE_KEY=${SUPABASE_PUBLISHABLE_KEY} depends_on: backend: condition: service_healthy @@ -29,10 +26,13 @@ services: build: context: ./backend dockerfile: Dockerfile - ports: - - "8000:8000" + env_file: + - ./backend/.env + # Supabase CLI binds locally to the host. This local stack targets Linux. + network_mode: host volumes: - ./backend:/app + - backend_packages:/app/.venv healthcheck: test: ["CMD", "python3", "-c", "import urllib.request, sys; res = urllib.request.urlopen('http://localhost:8000/committees/health', timeout=3); @@ -51,3 +51,4 @@ services: volumes: packages: + backend_packages: From 39d1566fdfc772c43317394bf50f19def8b98ae2 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:41:07 -0300 Subject: [PATCH 22/28] chore: delete root .env --- .env.example | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index 973fdcc..0000000 --- a/.env.example +++ /dev/null @@ -1,7 +0,0 @@ -# Settings/authService -SUPABASE_URL=http_url_to_supabase -SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key_here -SUPABASE_JWT_SECRET=jwt_secret here -JWT_ALGORITHM=HS256 - -DATABASE_URL=direct_url_to_supabase From 92f4225051d58b845d83695d9b516d2659d7d6f3 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:09:19 -0300 Subject: [PATCH 23/28] fix(dev): refresh frontend schemas and dependencies Co-authored-by: Codex --- docker-compose.yml | 14 +++-- frontend/openapi-ts.config.ts | 4 +- frontend/src/schemas/types.gen.ts | 88 ++++++++++++++++++++++--------- 3 files changed, 75 insertions(+), 31 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b8f9b16..51a5fd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,10 +4,9 @@ services: context: ./frontend dockerfile: Dockerfile user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" - ports: - - "5173:5173" env_file: - ./frontend/.env.local + network_mode: host volumes: - ./frontend:/app - packages:/app/node_modules @@ -16,7 +15,16 @@ services: condition: service_healthy restart: true command: > - sh -c 'npm run generate:schema && npm run dev -- --host 0.0.0.0 --port 5173' + sh -ec ' + lock_hash=$$(sha256sum package-lock.json | cut -d " " -f 1); + if [ ! -f node_modules/.webmun-lock-hash ] || + [ "$$(cat node_modules/.webmun-lock-hash)" != "$$lock_hash" ]; then + npm ci; + printf "%s" "$$lock_hash" > node_modules/.webmun-lock-hash; + fi; + npm run generate:schema; + exec npm run dev -- --host 0.0.0.0 --port 5173 + ' develop: watch: - action: restart diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts index 28b234f..4d793e2 100644 --- a/frontend/openapi-ts.config.ts +++ b/frontend/openapi-ts.config.ts @@ -2,7 +2,7 @@ Configuração para geração automática do types.gen.ts para Schemas na frontend */ export default { - input: 'http://backend:8000/openapi.json', + input: process.env.OPENAPI_URL ?? 'http://localhost:8000/openapi.json', output: { entryFile: false, path: 'src/schemas', @@ -13,4 +13,4 @@ export default { name: '@hey-api/typescript', }, ] -}; \ No newline at end of file +}; diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index da8dbbc..8c949a1 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts export type ClientOptions = { - baseUrl: 'http://backend:8000' | (string & {}); + baseUrl: 'http://localhost:8000' | (string & {}); }; /** @@ -400,24 +400,6 @@ export type DelegationContext = { code: string; }; -/** - * DelegationSchema - */ -export type DelegationSchema = { - /** - * Name - */ - name: string; - /** - * Seat - */ - seat: string; - /** - * Code - */ - code: string; -}; - /** * EmptyPayload */ @@ -687,20 +669,18 @@ export type RollCallContext = { /** * SessionCreationSchema + * + * Schema to create a session. Follows a DB schema + extra configs format */ export type SessionCreationSchema = { /** - * Session Id + * Committee Id */ - session_id: number; + committee_id: number; /** * Name */ name?: string | null; - /** - * Delegations - */ - delegations: Array; }; /** @@ -983,10 +963,66 @@ export type CreateSessionEndpointCommitteesPostErrors = { export type CreateSessionEndpointCommitteesPostError = CreateSessionEndpointCommitteesPostErrors[keyof CreateSessionEndpointCommitteesPostErrors]; export type CreateSessionEndpointCommitteesPostResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ActivateSessionEndpointCommitteesSessionIdActivatePostData = { + body?: never; + path: { + /** + * Session Id + */ + session_id: number; + }; + query?: never; + url: '/committees/{session_id}/activate'; +}; + +export type ActivateSessionEndpointCommitteesSessionIdActivatePostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ActivateSessionEndpointCommitteesSessionIdActivatePostError = ActivateSessionEndpointCommitteesSessionIdActivatePostErrors[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostErrors]; + +export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponses = { /** * Successful Response */ 204: void; }; -export type CreateSessionEndpointCommitteesPostResponse = CreateSessionEndpointCommitteesPostResponses[keyof CreateSessionEndpointCommitteesPostResponses]; +export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponse = ActivateSessionEndpointCommitteesSessionIdActivatePostResponses[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostResponses]; + +export type GetMySessionAccessAccessSessionsSessionIdMeGetData = { + body?: never; + path: { + /** + * Session Id + */ + session_id: number; + }; + query?: never; + url: '/access/sessions/{session_id}/me'; +}; + +export type GetMySessionAccessAccessSessionsSessionIdMeGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetMySessionAccessAccessSessionsSessionIdMeGetError = GetMySessionAccessAccessSessionsSessionIdMeGetErrors[keyof GetMySessionAccessAccessSessionsSessionIdMeGetErrors]; + +export type GetMySessionAccessAccessSessionsSessionIdMeGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; From e7c88b1ed724a8e789510bfbdd54af95ed36dcf8 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:12:10 -0300 Subject: [PATCH 24/28] docs: adds instructions for dev workflow --- README.md | 14 ++++++++++++++ frontend/README.md | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ec4963e..b0cefe7 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,17 @@ This project stack is composed of: - Redis for in-memory database Further improvements are planned to make this a cloud-native application. + +# Local development + +To start the backend, frontend and supabase, issue: + +``` +make dev +``` + +To stop these services, do: + +``` +make stop +``` diff --git a/frontend/README.md b/frontend/README.md index 3d1aabd..ce7a9e4 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -10,7 +10,7 @@ Prerequisites: Node 20+ ```bash npm install -cp .env.example .env +cp .env.example .env.local ``` 2. Run the project using `npm` From 13a4a0fd0563447dbf35ceac41733d6ac3b92f1d Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:33:57 -0300 Subject: [PATCH 25/28] fix: fixes websocket connection and state_snapshot json insertion --- backend/app/core/dep.py | 10 +++++----- backend/app/session/repository.py | 3 ++- frontend/src/pages/Session.tsx | 18 +++++++++++------- supabase/users.sql | 2 +- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/backend/app/core/dep.py b/backend/app/core/dep.py index b3f27d7..9542dfa 100644 --- a/backend/app/core/dep.py +++ b/backend/app/core/dep.py @@ -1,17 +1,17 @@ -from fastapi import Request +from fastapi.requests import HTTPConnection from app.session.manager import ConnectionManager from app.session.engine import SessionEngine import logging -def get_connection_manager(request: Request) -> ConnectionManager: +def get_connection_manager(connection: HTTPConnection) -> ConnectionManager: """Dependency injection for the app connection manager""" - return request.app.state.connection_manager + return connection.app.state.connection_manager -def get_session_engine(request: Request) -> SessionEngine: +def get_session_engine(connection: HTTPConnection) -> SessionEngine: """Dependency injection for the app session engine""" - return request.app.state.session_engine + return connection.app.state.session_engine def get_logger() -> logging.Logger: diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py index a8dda27..ced892c 100644 --- a/backend/app/session/repository.py +++ b/backend/app/session/repository.py @@ -3,6 +3,7 @@ from app.access.models import CommitteeAssignment from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +import json from app.session.models import DelegationContext, StoredSession @@ -88,7 +89,7 @@ async def update_session_info( "status": session_info.status, "started_at": session_info.started_at, "ended_at": session_info.ended_at, - "state_snapshot": session_info.state_snapshot, + "state_snapshot": json.dumps(session_info.state_snapshot), "committee_session_id": session_info.id } ) diff --git a/frontend/src/pages/Session.tsx b/frontend/src/pages/Session.tsx index e9f0ef4..dfb281f 100644 --- a/frontend/src/pages/Session.tsx +++ b/frontend/src/pages/Session.tsx @@ -77,24 +77,28 @@ export default function SessionPage() { return; } - socket = new WebSocket( + const ws = new WebSocket( `${import.meta.env.VITE_WS_URL}/ws/${parsedSessionId}`, ); + socket = ws; - socket.onopen = () => { - socket?.send(JSON.stringify({ access_token: token })); + ws.onopen = () => { + ws?.send(JSON.stringify({ access_token: token })); setStatus("Connected"); } - socket.onmessage = (event) => { + ws.onmessage = (event) => { const data = JSON.parse(event.data); + console.log(data); UpdateStore(data); - console.log(all); }; - socket.onclose = () => setStatus("Disconnected"); + ws.onclose = () => setStatus("Disconnected"); - return () => socket?.close(); // Cleanup on unmount + return () => { + ws.close(); + if (socket === ws) socket = null; + } }, [parsedSessionId, token]); if (loading) return

Loading session…

; diff --git a/supabase/users.sql b/supabase/users.sql index 1190ccb..b5867fa 100644 --- a/supabase/users.sql +++ b/supabase/users.sql @@ -33,7 +33,7 @@ BEGIN VALUES (gen_random_uuid(), id_alemanha, 'alemanha@codelab.usp.br', format('{"sub":"%s","email":"alemanha@codelab.usp.br"}', id_alemanha)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; INSERT INTO auth.users (id, instance_id, aud, role, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at, updated_at, confirmation_token, recovery_token, email_change_token_new, email_change) - VALUES (id_brazil, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'brazil@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "alemanha"}'::jsonb, now(), now(), '', '', '', '') ON CONFLICT (id) DO NOTHING; + VALUES (id_brazil, '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'brazil@codelab.usp.br', crypt('password123', gen_salt('bf')), now(), '{"name": "brazil"}'::jsonb, now(), now(), '', '', '', '') ON CONFLICT (id) DO NOTHING; INSERT INTO auth.identities (id, user_id, provider_id, identity_data, provider, last_sign_in_at, created_at, updated_at) VALUES (gen_random_uuid(), id_brazil, 'brazil@codelab.usp.br', format('{"sub":"%s","email":"brazil@codelab.usp.br"}', id_brazil)::jsonb, 'email', now(), now(), now()) ON CONFLICT (provider, provider_id) DO NOTHING; From e5ac790b7d07ac8b7bb2b82fdfc3e7b03fc0ac41 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:34:28 -0300 Subject: [PATCH 26/28] feat: adds prepare_session_connect function that rehydrates missing manager live states --- backend/app/session/service.py | 39 +++++++++++++++++++++++++++++++++- backend/app/session/views.py | 24 ++++++++++++--------- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/backend/app/session/service.py b/backend/app/session/service.py index d0dc98e..85b21a0 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -157,7 +157,44 @@ async def close_session( manager: ConnectionManager, committee_session_id: int, ) -> None: - pass + pass + +async def prepare_session_connect( + session: AsyncSession, + manager: ConnectionManager, + committee_session_id: int, + assignment: CommitteeAssignment, +) -> SessionActor: + """Service that prepares for session connect. + Primarily used as a fallback in case the SessionLiveState is not in manager + """ + live_state = manager.room_states.get(committee_session_id) + + if live_state is None: + stored_state = await repository.get_session_info(session, committee_session_id) + if stored_state is None: + raise SessionFetchError("Could not fetch session info") + if stored_state.status != 'active': + raise SessionFetchError("Session is not active") + if stored_state.state_snapshot is None: + raise SessionFetchError("No state snapshot found") + + # fetch state_snapshot and validate to be a SessionLiveState + live_state = SessionLiveState.model_validate(stored_state.state_snapshot) + + # put SessionLiveState on ConnectionManager + manager.room_states[committee_session_id] = live_state + manager.active_connections.setdefault(committee_session_id, {}) + + actor = build_actor( + manager=manager, + session_id=committee_session_id, + role=enums.SessionRole(assignment.role.upper()), + delegation_id=assignment.representation_id, + ) + + return actor + """ async def old_create_session_service( diff --git a/backend/app/session/views.py b/backend/app/session/views.py index a6274dc..665a70f 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -22,7 +22,7 @@ from app.core.config import get_settings from app.core.database import get_db_session from app.session.engine import SessionEngine -from app.session.enums import ChairEvents, DelegateEvents, SessionRole +from app.session.enums import ChairEvents, DelegateEvents from app.session.manager import ConnectionManager from app.session.models import SessionLiveState from app.session.schemas import SessionCreationSchema, SessionEvent @@ -57,7 +57,7 @@ async def health(): async def create_session_endpoint( session_schema: SessionCreationSchema, session: Annotated[AsyncSession, Depends(get_db_session)], - current_user: Annotated[AuthUser, Depends(get_current_user)], # TODO: map out that only admins can create sessions + current_user: Annotated[AuthUser, Depends(get_current_user)], ): """POST endpoint to create a new session""" try: @@ -166,12 +166,12 @@ async def websocket_endpoint( session=db, user_id=auth_user.user_id, session_id=session_id ) - actor = service.build_actor( - manager=manager, - session_id=session_id, - role=SessionRole(assignment.role.upper()), - delegation_id=assignment.representation_id, - ) + actor = await service.prepare_session_connect( + session=db, + manager=manager, + committee_session_id=session_id, + assignment=assignment + ) await manager.connect(websocket, session_id, actor) try: @@ -189,13 +189,17 @@ async def websocket_endpoint( except WebSocketDisconnect: manager.disconnect(websocket, session_id) - except (TokenExpiredError, TokenInvalidError, AccessDenied, service.ActorResolutionError) as exc: - if isinstance(exc, TokenExpiredError): + except (TokenExpiredError, TokenInvalidError, AccessDenied, service.ActorResolutionError, WebSocketDisconnect, service.SessionFetchError) as exc: + if isinstance(exc, WebSocketDisconnect): + reason = "websocket_disconnect" + elif isinstance(exc, TokenExpiredError): reason = "token_expired" elif isinstance(exc, TokenInvalidError): reason = "token_invalid" elif isinstance(exc, AccessDenied): reason = "access_denied" + elif isinstance(exc, service.SessionFetchError): + reason = "session_unavailable" else: reason = "actor_resolution_error" From c9404f157fdc7f3b5e82e3cd313821c9d1418589 Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:34:52 -0300 Subject: [PATCH 27/28] tests: add tests for prepare_session --- backend/app/tests/access/test_service.py | 4 +- backend/app/tests/session/test_service.py | 110 +++++++++++++++++++++- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/backend/app/tests/access/test_service.py b/backend/app/tests/access/test_service.py index 6f9077a..e3ab09d 100644 --- a/backend/app/tests/access/test_service.py +++ b/backend/app/tests/access/test_service.py @@ -92,4 +92,6 @@ async def chair_assignment(*_args, **_kwargs): monkeypatch.setattr("app.access.service.get_committee_assignment", chair_assignment) - assert await verify_user_role(object(), assignment.user_id, 1, "chair") is assignment + assert ( + await verify_user_role(object(), assignment.user_id, 1, "chair") is assignment + ) diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index ebc91aa..0758c38 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -1,13 +1,33 @@ # Test suite for service layer import json +from uuid import UUID import pytest +from app.access.models import CommitteeAssignment from app.session import enums from app.session.manager import ConnectionManager from app.session.models import SessionActor, SessionLiveState +from app.session.repository import get_session_info from app.session.enums import SessionRole -from app.session.service import ActorResolutionError, build_actor, handle_client_messages +from app.session.service import ( + ActorResolutionError, + SessionFetchError, + build_actor, + handle_client_messages, + prepare_session_connect, +) +from unittest.mock import AsyncMock, MagicMock + + +@pytest.fixture +def brazil_assignment(): + return CommitteeAssignment( + user_id=UUID("44444444-4444-4444-4444-444444444444"), + committee_id=0, + role="delegate", + representation_id=0, + ) def test_can_build_actor( @@ -26,7 +46,9 @@ def test_can_build_actor( assert actor.role == SessionRole.DELEGATE assert actor.delegation is not None assert actor.delegation.id == connection_manager.room_states[0].delegations[0].id - assert actor.delegation.name == connection_manager.room_states[0].delegations[0].name + assert ( + actor.delegation.name == connection_manager.room_states[0].delegations[0].name + ) def test_cannot_build_actor_with_nonexistent_state( @@ -68,6 +90,86 @@ def test_cannot_build_actor_with_nonexistent_delegation( delegation_id=999, ) + +@pytest.mark.anyio +async def test_prepare_connect_without_db( + connection_manager: ConnectionManager, + session_state: SessionLiveState, + brazil_assignment: CommitteeAssignment, +) -> None: + connection_manager.room_states[0] = session_state + mock_session = None + + actor = await prepare_session_connect( + session=mock_session, # type: ignore due to mock session not needing to be called. if it is, we fail + manager=connection_manager, + committee_session_id=0, + assignment=brazil_assignment, + ) + + assert actor.role == enums.SessionRole.DELEGATE + assert actor.delegation is not None + assert actor.delegation.id == brazil_assignment.representation_id + + +@pytest.mark.anyio +async def test_prepare_connect_fetches_db( + connection_manager: ConnectionManager, + brazil_assignment: CommitteeAssignment, + monkeypatch, +) -> None: + mock_session = MagicMock + mock_stored_state = MagicMock() + mock_stored_state.status = "active" + mock_stored_state.state_snapshot = {"session_id": 0} + + mock_get_session_info = AsyncMock(return_value=mock_stored_state) + + monkeypatch.setattr( + "app.session.repository.get_session_info", mock_get_session_info + ) + + # prevent model_validate from breaking validation + monkeypatch.setattr( + SessionLiveState, + "model_validate", + MagicMock(return_value=mock_stored_state.state_snapshot), + ) + + monkeypatch.setattr("app.session.service.build_actor", MagicMock()) + + await prepare_session_connect( + session=mock_session, # type: ignore + manager=connection_manager, + committee_session_id=0, + assignment=brazil_assignment, + ) + + mock_get_session_info.assert_called_once_with(mock_session, 0) + + +@pytest.mark.anyio +async def test_cant_prepare_connect_storedlive_missing( + connection_manager: ConnectionManager, + brazil_assignment: CommitteeAssignment, + monkeypatch, +) -> None: + with pytest.raises(SessionFetchError, match="Could not fetch session info"): + mock_session = MagicMock + mock_get_session_info = AsyncMock(return_value=None) + + monkeypatch.setattr( + "app.session.repository.get_session_info", mock_get_session_info + ) + + await prepare_session_connect( + session=mock_session, # type: ignore + manager=connection_manager, + committee_session_id=0, + assignment=brazil_assignment, + ) + + @pytest.mark.anyio async def test_handle_client_messages_dispatches_and_broadcasts( connection_manager: ConnectionManager, @@ -97,7 +199,9 @@ async def fake_broadcast_state(session_id: int): manager=connection_manager, engine=fake_engine, logger=__import__("logging").getLogger("test"), - session_id=session_state.session_id, actor=delegate_actor, data=data + session_id=session_state.session_id, + actor=delegate_actor, + data=data, ) # tests if engine dispatched the message From 8416bd43cb6f35e606cff173f3d5e0bdc8c730fc Mon Sep 17 00:00:00 2001 From: wate <110754234+r0liveir@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:52:45 -0300 Subject: [PATCH 28/28] fix: fix ws disconnect log message when using React Strict Mode --- backend/app/session/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/app/session/views.py b/backend/app/session/views.py index 665a70f..2e8649e 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -188,8 +188,10 @@ async def websocket_endpoint( except WebSocketDisconnect: manager.disconnect(websocket, session_id) - - except (TokenExpiredError, TokenInvalidError, AccessDenied, service.ActorResolutionError, WebSocketDisconnect, service.SessionFetchError) as exc: + except WebSocketDisconnect: + # this is reached when the ws is disconnected before reaching manager.connect. in this case, just return + return + except (TokenExpiredError, TokenInvalidError, AccessDenied, service.ActorResolutionError, service.SessionFetchError) as exc: if isinstance(exc, WebSocketDisconnect): reason = "websocket_disconnect" elif isinstance(exc, TokenExpiredError):