diff --git a/.github/workflows/azure-deploy.yml b/.github/workflows/azure-deploy.yml index 4fd868a..cbfe9d8 100644 --- a/.github/workflows/azure-deploy.yml +++ b/.github/workflows/azure-deploy.yml @@ -53,6 +53,34 @@ jobs: echo "image_uri=${IMAGE_URI}" >> "$GITHUB_OUTPUT" echo "Deploying ${IMAGE_URI}" + - name: Check out deployed commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install migration dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e . + + - name: Run database migrations + shell: bash + run: | + DATABASE_URL="$(az containerapp secret show \ + --name "$AZURE_CONTAINER_APP" \ + --resource-group "$AZURE_RESOURCE_GROUP" \ + --secret-name database-url \ + --query value \ + --output tsv)" + echo "::add-mask::${DATABASE_URL}" + export DATABASE_URL + alembic upgrade head + - name: Update Azure Container App run: | az containerapp update \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b47efd3..11dc4fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,30 @@ jobs: name: Backend tests runs-on: ubuntu-latest + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: supportagent + POSTGRES_PASSWORD: supportagent + POSTGRES_DB: supportagent + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U supportagent" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + CHAT_MODEL: qwen-plus + CHAT_MODELS: qwen-plus,qwen-max,qwen3-max + EMBEDDING_API_KEY: ci-placeholder + EMBEDDING_BASE_URL: https://example.invalid/v1 + DATABASE_URL: postgresql://supportagent:supportagent@localhost:5432/supportagent + TEST_DATABASE_URL: postgresql://supportagent:supportagent@localhost:5432/supportagent_test + MCP_DYNAMIC_TOOLS_ENABLED: "false" + steps: - name: Checkout uses: actions/checkout@v4 @@ -29,15 +53,11 @@ jobs: run: python -m compileall -q src/backend/supportagent - name: Run backend tests - env: - CHAT_MODEL: qwen-plus - CHAT_MODELS: qwen-plus,qwen-max,qwen3-max - EMBEDDING_API_KEY: ci-placeholder - EMBEDDING_BASE_URL: https://example.invalid/v1 - DATABASE_URL: postgresql://supportagent:supportagent@localhost:5432/supportagent - MCP_DYNAMIC_TOOLS_ENABLED: "false" run: python -m pytest + - name: Run integration tests + run: python -m pytest tests/integration --run-integration --deselect tests/integration/storage + - name: Run offline claim evaluation run: | python -m supportagent.evaluation \ diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..abb1db9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,149 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..da7f2b3 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,28 @@ +from logging.config import fileConfig + +from alembic import context + +from supportagent.db import models # noqa: F401 +from supportagent.db.base import Base +from supportagent.db.session import engine + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_online() -> None: + with engine.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..6206b78 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,29 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import pgvector.sqlalchemy # noqa: F401 +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0fdad45ad4f7_add_hnsw_index_on_chunk_embeddings.py b/migrations/versions/0fdad45ad4f7_add_hnsw_index_on_chunk_embeddings.py new file mode 100644 index 0000000..e535ec8 --- /dev/null +++ b/migrations/versions/0fdad45ad4f7_add_hnsw_index_on_chunk_embeddings.py @@ -0,0 +1,33 @@ +"""Add HNSW index on chunk embeddings + +Revision ID: 0fdad45ad4f7 +Revises: 267f87b25a77 +Create Date: 2026-10-29 15:22:51.359910 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import pgvector.sqlalchemy # noqa: F401 + + +# revision identifiers, used by Alembic. +revision: str = '0fdad45ad4f7' +down_revision: Union[str, Sequence[str], None] = '267f87b25a77' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('chunks_embedding_hnsw_idx', 'chunks', ['embedding'], unique=False, postgresql_using='hnsw', postgresql_ops={'embedding': 'vector_cosine_ops'}) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('chunks_embedding_hnsw_idx', table_name='chunks', postgresql_using='hnsw', postgresql_ops={'embedding': 'vector_cosine_ops'}) + # ### end Alembic commands ### diff --git a/migrations/versions/267f87b25a77_seed_mcp_servers_and_tool_policies.py b/migrations/versions/267f87b25a77_seed_mcp_servers_and_tool_policies.py new file mode 100644 index 0000000..c664793 --- /dev/null +++ b/migrations/versions/267f87b25a77_seed_mcp_servers_and_tool_policies.py @@ -0,0 +1,80 @@ +"""seed mcp servers and tool policies + +Revision ID: 267f87b25a77 +Revises: 863e47af17d2 +Create Date: 2026-10-27 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = '267f87b25a77' +down_revision: Union[str, Sequence[str], None] = '863e47af17d2' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Seed the default MCP servers and their tool policies.""" + # Values are copied, not imported: this migration must insert the same rows + # forever, even after the constants in mcp_client/store.py change. + # Servers first: mcp_tool_policies.server_name references mcp_servers. + op.execute( + """ + INSERT INTO mcp_servers (server_name, display_name, transport, config, enabled) + VALUES + ('time_mcp', 'Local timezone-aware time MCP server', 'stdio', + '{"command": "python", "args": ["-m", "supportagent.mcp_servers.time_mcp", "--transport", "stdio"]}', + true), + ('weather_mcp', 'Local Google Weather MCP server', 'stdio', + '{"command": "python", "args": ["-m", "supportagent.mcp_servers.weather_mcp", "--transport", "stdio"]}', + true), + ('teams_mcp', 'Local Microsoft Graph MCP server', 'stdio', + '{"command": "python", "args": ["-m", "supportagent.mcp_servers.teams_mcp", "--transport", "stdio"]}', + true) + ON CONFLICT (server_name) DO NOTHING + """ + ) + # Columns: enabled, auto_allowed, requires_confirmation. + # Read-only tools run automatically, write tools need confirmation, + # and tools a personal Microsoft account cannot use are disabled. + op.execute( + """ + INSERT INTO mcp_tool_policies + (server_name, tool_name, enabled, auto_allowed, requires_confirmation) + VALUES + ('time_mcp', 'get_current_time', true, true, false), + ('weather_mcp', 'get_weather', true, true, false), + ('teams_mcp', 'get_my_profile', true, true, false), + ('teams_mcp', 'batch_get_user_info', false, false, false), + ('teams_mcp', 'create_calendar', true, false, true), + ('teams_mcp', 'delete_calendar', true, false, true), + ('teams_mcp', 'get_calendar_info', true, true, false), + ('teams_mcp', 'get_calendars_list', true, true, false), + ('teams_mcp', 'update_calendar', true, false, true), + ('teams_mcp', 'create_calendar_event', true, false, true), + ('teams_mcp', 'create_default_calendar_event', true, false, true), + ('teams_mcp', 'append_calendar_event_attendee', true, false, true), + ('teams_mcp', 'get_calendar_event', true, true, false), + ('teams_mcp', 'update_calendar_event', true, false, true), + ('teams_mcp', 'delete_calendar_event', true, false, true), + ('teams_mcp', 'create_document', true, false, true), + ('teams_mcp', 'get_document', true, true, false), + ('teams_mcp', 'create_folder', true, false, true), + ('teams_mcp', 'list_folder_files', true, true, false), + ('teams_mcp', 'list_chats', false, false, false), + ('teams_mcp', 'create_message', false, false, false) + ON CONFLICT (server_name, tool_name) DO NOTHING + """ + ) + + +def downgrade() -> None: + """Keep the rows. + + Existing databases already had these rows before this migration ran, and + deleting a server cascades to users' saved credentials. + """ diff --git a/migrations/versions/863e47af17d2_baseline.py b/migrations/versions/863e47af17d2_baseline.py new file mode 100644 index 0000000..a1d80de --- /dev/null +++ b/migrations/versions/863e47af17d2_baseline.py @@ -0,0 +1,308 @@ +"""baseline + +Revision ID: 863e47af17d2 +Revises: +Create Date: 2026-10-23 19:06:46.921494 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import pgvector.sqlalchemy # noqa: F401 +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '863e47af17d2' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('chunks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('document_id', sa.Text(), nullable=False), + sa.Column('chunk_index', sa.Integer(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('chunks_pkey')), + sa.UniqueConstraint('document_id', 'chunk_index', name=op.f('chunks_document_id_chunk_index_key')) + ) + op.create_table('claims', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('owner_user_id', sa.Text(), nullable=False), + sa.Column('policy_id', sa.Text(), nullable=False), + sa.Column('product_line', sa.Text(), server_default='unknown', nullable=False), + sa.Column('policy_version', sa.Text(), nullable=True), + sa.Column('jurisdiction', sa.Text(), server_default='DE', nullable=False), + sa.Column('customer_reference', sa.Text(), nullable=False), + sa.Column('claim_type', sa.Text(), nullable=False), + sa.Column('incident_date', sa.Text(), nullable=True), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('claims_pkey')) + ) + op.create_index('claims_owner_updated_idx', 'claims', ['owner_user_id', sa.literal_column('updated_at DESC')], unique=False) + op.create_table('conversations', + sa.Column('thread_id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('thread_id', name=op.f('conversations_pkey')) + ) + op.create_table('mcp_idempotency_records', + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('server_name', sa.Text(), nullable=False), + sa.Column('tool_name', sa.Text(), nullable=False), + sa.Column('idempotency_key', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('result', sa.Text(), nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("status IN ('in_progress', 'succeeded')", name='mcp_idempotency_records_status_check'), + sa.PrimaryKeyConstraint('user_id', 'server_name', 'tool_name', 'idempotency_key', name=op.f('mcp_idempotency_records_pkey')) + ) + op.create_table('mcp_oauth_states', + sa.Column('state', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('provider', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('state', name=op.f('mcp_oauth_states_pkey')) + ) + op.create_table('mcp_servers', + sa.Column('server_name', sa.Text(), nullable=False), + sa.Column('display_name', sa.Text(), nullable=False), + sa.Column('transport', sa.Text(), nullable=False), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("transport IN ('stdio', 'sse', 'streamable_http')", name='mcp_servers_transport_check'), + sa.PrimaryKeyConstraint('server_name', name=op.f('mcp_servers_pkey')) + ) + op.create_table('mcp_tool_audit_logs', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('server_name', sa.Text(), nullable=False), + sa.Column('tool_name', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('arguments', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('result_preview', sa.Text(), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("status IN ('success', 'error', 'blocked')", name='mcp_tool_audit_logs_status_check'), + sa.PrimaryKeyConstraint('id', name=op.f('mcp_tool_audit_logs_pkey')) + ) + op.create_index('mcp_tool_audit_user_created_idx', 'mcp_tool_audit_logs', ['user_id', sa.literal_column('created_at DESC')], unique=False) + op.create_table('uploaded_files', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('thread_id', sa.Text(), nullable=True), + sa.Column('filename', sa.Text(), nullable=False), + sa.Column('content_type', sa.Text(), nullable=False), + sa.Column('storage_provider', sa.Text(), server_default='local', nullable=False), + sa.Column('storage_bucket', sa.Text(), server_default='local', nullable=False), + sa.Column('storage_key', sa.Text(), nullable=True), + sa.Column('storage_path', sa.Text(), nullable=True), + sa.Column('image_analysis', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('image_summary', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('uploaded_files_pkey')) + ) + op.create_index('uploaded_files_user_created_idx', 'uploaded_files', ['user_id', sa.literal_column('created_at DESC')], unique=False) + op.create_table('users', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('email', sa.Text(), nullable=False), + sa.Column('display_name', sa.Text(), nullable=True), + sa.Column('password_hash', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('users_pkey')), + sa.UniqueConstraint('email', name=op.f('users_email_key')) + ) + op.create_table('claim_audit_events', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('claim_id', sa.Text(), nullable=False), + sa.Column('actor_user_id', sa.Text(), nullable=False), + sa.Column('event_type', sa.Text(), nullable=False), + sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['claim_id'], ['claims.id'], name=op.f('claim_audit_events_claim_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('claim_audit_events_pkey')) + ) + op.create_index('claim_audit_claim_idx', 'claim_audit_events', ['claim_id', 'created_at'], unique=False) + op.create_table('claim_documents', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('claim_id', sa.Text(), nullable=False), + sa.Column('uploaded_file_id', sa.Text(), nullable=True), + sa.Column('document_type', sa.Text(), nullable=False), + sa.Column('filename', sa.Text(), nullable=False), + sa.Column('extraction_status', sa.Text(), server_default='PENDING', nullable=False), + sa.Column('extracted_fields', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['claim_id'], ['claims.id'], name=op.f('claim_documents_claim_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('claim_documents_pkey')) + ) + op.create_index('claim_documents_claim_idx', 'claim_documents', ['claim_id', 'created_at'], unique=False) + op.create_table('claim_review_runs', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('claim_id', sa.Text(), nullable=False), + sa.Column('owner_user_id', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('current_step', sa.Text(), nullable=False), + sa.Column('result', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('error', sa.Text(), nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('completed_at', postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['claim_id'], ['claims.id'], name=op.f('claim_review_runs_claim_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('claim_review_runs_pkey')) + ) + op.create_index('claim_review_runs_claim_idx', 'claim_review_runs', ['claim_id', sa.literal_column('created_at DESC')], unique=False) + op.create_table('conversation_messages', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('thread_id', sa.Text(), nullable=False), + sa.Column('role', sa.Text(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("role IN ('user', 'assistant')", name='conversation_messages_role_check'), + sa.ForeignKeyConstraint(['thread_id'], ['conversations.thread_id'], name=op.f('conversation_messages_thread_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('conversation_messages_pkey')) + ) + op.create_index('conversation_messages_thread_created_idx', 'conversation_messages', ['thread_id', sa.literal_column('created_at DESC')], unique=False) + op.create_table('long_memories', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('thread_id', sa.Text(), nullable=True), + sa.Column('kind', sa.Text(), server_default='conversation_summary', nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('embedding', pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=True), + sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['thread_id'], ['conversations.thread_id'], name=op.f('long_memories_thread_id_fkey'), ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('long_memories_pkey')) + ) + op.create_index('long_memories_user_created_idx', 'long_memories', ['user_id', sa.literal_column('created_at DESC')], unique=False) + op.create_table('mcp_tool_policies', + sa.Column('server_name', sa.Text(), nullable=False), + sa.Column('tool_name', sa.Text(), nullable=False), + sa.Column('enabled', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('auto_allowed', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('requires_confirmation', sa.Boolean(), server_default=sa.text('true'), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['server_name'], ['mcp_servers.server_name'], name=op.f('mcp_tool_policies_server_name_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('server_name', 'tool_name', name=op.f('mcp_tool_policies_pkey')) + ) + op.create_table('mcp_user_credentials', + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('server_name', sa.Text(), nullable=False), + sa.Column('credentials', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['server_name'], ['mcp_servers.server_name'], name=op.f('mcp_user_credentials_server_name_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'server_name', name=op.f('mcp_user_credentials_pkey')) + ) + op.create_table('password_reset_tokens', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('token_hash', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.Column('used_at', postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('password_reset_tokens_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('password_reset_tokens_pkey')), + sa.UniqueConstraint('token_hash', name=op.f('password_reset_tokens_token_hash_key')) + ) + op.create_index('password_reset_tokens_token_hash_idx', 'password_reset_tokens', ['token_hash'], unique=False) + op.create_index('password_reset_tokens_user_expires_idx', 'password_reset_tokens', ['user_id', sa.literal_column('expires_at DESC')], unique=False) + op.create_table('proposed_actions', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('claim_id', sa.Text(), nullable=False), + sa.Column('run_id', sa.Text(), nullable=True), + sa.Column('action_type', sa.Text(), nullable=False), + sa.Column('tool_server', sa.Text(), nullable=False), + sa.Column('tool_name', sa.Text(), nullable=False), + sa.Column('arguments', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column('reason', sa.Text(), nullable=False), + sa.Column('risk_level', sa.Text(), nullable=False), + sa.Column('status', sa.Text(), nullable=False), + sa.Column('proposed_by', sa.Text(), nullable=False), + sa.Column('approved_by', sa.Text(), nullable=True), + sa.Column('approved_at', postgresql.TIMESTAMP(timezone=True), nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['claim_id'], ['claims.id'], name=op.f('proposed_actions_claim_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('proposed_actions_pkey')) + ) + op.create_index('proposed_actions_claim_idx', 'proposed_actions', ['claim_id', sa.literal_column('created_at DESC')], unique=False) + op.create_table('user_sessions', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('token_hash', sa.Text(), nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('expires_at', postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('user_sessions_user_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('user_sessions_pkey')), + sa.UniqueConstraint('token_hash', name=op.f('user_sessions_token_hash_key')) + ) + op.create_index('user_sessions_token_hash_idx', 'user_sessions', ['token_hash'], unique=False) + op.create_index('user_sessions_user_expires_idx', 'user_sessions', ['user_id', sa.literal_column('expires_at DESC')], unique=False) + op.create_table('claim_action_approvals', + sa.Column('id', sa.BigInteger(), nullable=False), + sa.Column('proposed_action_id', sa.Text(), nullable=False), + sa.Column('actor_user_id', sa.Text(), nullable=False), + sa.Column('decision', sa.Text(), nullable=False), + sa.Column('comment', sa.Text(), nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.CheckConstraint("decision IN ('APPROVED', 'REJECTED')", name='claim_action_approvals_decision_check'), + sa.ForeignKeyConstraint(['proposed_action_id'], ['proposed_actions.id'], name=op.f('claim_action_approvals_proposed_action_id_fkey'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('claim_action_approvals_pkey')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('claim_action_approvals') + op.drop_index('user_sessions_user_expires_idx', table_name='user_sessions') + op.drop_index('user_sessions_token_hash_idx', table_name='user_sessions') + op.drop_table('user_sessions') + op.drop_index('proposed_actions_claim_idx', table_name='proposed_actions') + op.drop_table('proposed_actions') + op.drop_index('password_reset_tokens_user_expires_idx', table_name='password_reset_tokens') + op.drop_index('password_reset_tokens_token_hash_idx', table_name='password_reset_tokens') + op.drop_table('password_reset_tokens') + op.drop_table('mcp_user_credentials') + op.drop_table('mcp_tool_policies') + op.drop_index('long_memories_user_created_idx', table_name='long_memories') + op.drop_table('long_memories') + op.drop_index('conversation_messages_thread_created_idx', table_name='conversation_messages') + op.drop_table('conversation_messages') + op.drop_index('claim_review_runs_claim_idx', table_name='claim_review_runs') + op.drop_table('claim_review_runs') + op.drop_index('claim_documents_claim_idx', table_name='claim_documents') + op.drop_table('claim_documents') + op.drop_index('claim_audit_claim_idx', table_name='claim_audit_events') + op.drop_table('claim_audit_events') + op.drop_table('users') + op.drop_index('uploaded_files_user_created_idx', table_name='uploaded_files') + op.drop_table('uploaded_files') + op.drop_index('mcp_tool_audit_user_created_idx', table_name='mcp_tool_audit_logs') + op.drop_table('mcp_tool_audit_logs') + op.drop_table('mcp_servers') + op.drop_table('mcp_oauth_states') + op.drop_table('mcp_idempotency_records') + op.drop_table('conversations') + op.drop_index('claims_owner_updated_idx', table_name='claims') + op.drop_table('claims') + op.drop_table('chunks') + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index c3e154a..b3e538c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "supportagent" version = "0.1.0" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "pypdf", "openai", @@ -26,6 +26,8 @@ dependencies = [ "langchain-anthropic", "boto3", "boto3-stubs~=1.43.54", + "sqlalchemy>=2.0.54", + "alembic>=1.20.0", ] [project.optional-dependencies] @@ -41,7 +43,7 @@ include = ["supportagent*"] "" = "src/backend" [tool.ruff] -target-version = "py310" +target-version = "py311" src = ["src/backend"] [tool.vercel] diff --git a/scripts/check_models.py b/scripts/check_models.py new file mode 100644 index 0000000..d020124 --- /dev/null +++ b/scripts/check_models.py @@ -0,0 +1,31 @@ +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext + +import supportagent.db.models # noqa: F401 +from supportagent.db.base import Base +from supportagent.db.session import engine + +modeled = set(Base.metadata.tables) + + +def include_object(obj, name, type_, reflected, compare_to): + table = obj if type_ == "table" else obj.table + return table.name in modeled + + +with engine.connect() as conn: + ctx = MigrationContext.configure( + conn, + opts={ + "compare_type": True, + "compare_server_default": True, + "include_object": include_object, + }, + ) + diffs = compare_metadata(ctx, Base.metadata) + +print("check table:", ", ".join(sorted(modeled))) +if not diffs: + print("✅ Model and database are in sync") +for d in diffs: + print("❌", d) diff --git a/scripts/seed_claim_demo.py b/scripts/seed_claim_demo.py index 6726a5b..df8e2a3 100644 --- a/scripts/seed_claim_demo.py +++ b/scripts/seed_claim_demo.py @@ -11,39 +11,35 @@ if str(BACKEND) not in sys.path: sys.path.insert(0, str(BACKEND)) -from supportagent.auth.store import auth_connection, get_user_by_email # noqa: E402 -from supportagent.claims.store import ( # noqa: E402 - add_audit_event, - create_claim_schema, - insert_claim, - insert_document, -) +from sqlalchemy import select # noqa: E402 + +from supportagent.auth.store import get_user_by_email # noqa: E402 +from supportagent.claims.store import add_audit_event, insert_claim, insert_document # noqa: E402 +from supportagent.db.models import Claim as ClaimRow # noqa: E402 +from supportagent.db.session import SessionLocal # noqa: E402 def seed(owner_email: str) -> tuple[int, int]: fixtures = json.loads((ROOT / "data" / "synthetic_claims.json").read_text()) - conn = auth_connection() - try: - create_claim_schema(conn) - user_record = get_user_by_email(conn, owner_email.strip().casefold()) + with SessionLocal.begin() as session: + user_record = get_user_by_email(session, owner_email.strip().casefold()) if user_record is None: raise SystemExit(f"No registered user found for {owner_email!r}. Register in the app first.") user, _ = user_record claims_created = 0 documents_created = 0 for fixture in fixtures: - existing = conn.execute( - "SELECT owner_user_id FROM claims WHERE id = %s", - (fixture["id"],), - ).fetchone() - if existing: - if existing[0] != user.id: + existing_owner = session.scalar( + select(ClaimRow.owner_user_id).where(ClaimRow.id == fixture["id"]) + ) + if existing_owner is not None: + if existing_owner != user.id: raise SystemExit( f"Synthetic claim {fixture['id']} already belongs to another user; refusing to reassign it." ) continue claim = insert_claim( - conn, + session, { "id": fixture["id"], "owner_user_id": user.id, @@ -58,7 +54,7 @@ def seed(owner_email: str) -> tuple[int, int]: ) for index, document in enumerate(fixture["documents"], start=1): insert_document( - conn, + session, { "id": f"{fixture['id']}-DOC-{index:02d}", "claim_id": claim.id, @@ -70,17 +66,14 @@ def seed(owner_email: str) -> tuple[int, int]: ) documents_created += 1 add_audit_event( - conn, + session, claim.id, user.id, "SYNTHETIC_CLAIM_SEEDED", {"fixture_id": fixture["id"]}, ) claims_created += 1 - conn.commit() return claims_created, documents_created - finally: - conn.close() def main() -> None: diff --git a/scripts/start.py b/scripts/start.py index 0532626..a565a6d 100644 --- a/scripts/start.py +++ b/scripts/start.py @@ -87,21 +87,11 @@ def start_postgres() -> None: ) from exc -def wait_for_memory_schema() -> None: - print("Checking Postgres and creating memory schema if needed...") - code = ( - "from dotenv import load_dotenv; " - "load_dotenv(); " - "from supportagent.rag.vector_store import get_connection; " - "from supportagent.memory import create_memory_schema; " - "conn=get_connection(); " - "create_memory_schema(conn); " - "conn.close(); " - "print('memory schema ok')" - ) +def wait_for_database() -> None: + print("Waiting for Postgres and applying database migrations...") for attempt in range(1, 16): result = subprocess.run( - [python_command(), "-c", code], + [python_command(), "-m", "alembic", "upgrade", "head"], cwd=PROJECT_ROOT, env=project_env(), check=False, @@ -110,7 +100,7 @@ def wait_for_memory_schema() -> None: return print(f"Postgres is not ready yet ({attempt}/15). Waiting...") time.sleep(2) - raise SystemExit("Postgres did not become ready. Check docker compose logs postgres.") + raise SystemExit("Could not migrate the database. Check docker compose logs postgres.") def start_backend() -> subprocess.Popen: @@ -192,7 +182,7 @@ def main() -> None: ensure_ports_available() ensure_frontend_dependencies() start_postgres() - wait_for_memory_schema() + wait_for_database() start_backend() start_frontend() monitor() diff --git a/src/backend/supportagent/api/app.py b/src/backend/supportagent/api/app.py index 159466b..b491e7a 100644 --- a/src/backend/supportagent/api/app.py +++ b/src/backend/supportagent/api/app.py @@ -6,22 +6,17 @@ from supportagent.api.exception_handlers import register_exception_handlers from supportagent.api.middleware import register_request_logging from supportagent.api.router import register_routes -from supportagent.claims import ensure_claim_schema from supportagent.core.logging_config import configure_logging -from supportagent.mcp_client.store import ensure_mcp_schema -from supportagent.rag.vector_store import ensure_rag_schema -from supportagent.uploads import ensure_upload_schema +from supportagent.db.session import engine load_dotenv() configure_logging() + @asynccontextmanager async def lifespan(_app: FastAPI): - ensure_rag_schema() - ensure_mcp_schema() - ensure_upload_schema() - ensure_claim_schema() yield + engine.dispose() app = FastAPI(title="SupportAgent", lifespan=lifespan) diff --git a/src/backend/supportagent/api/health.py b/src/backend/supportagent/api/health.py index 537dda2..89ea5bc 100644 --- a/src/backend/supportagent/api/health.py +++ b/src/backend/supportagent/api/health.py @@ -1,4 +1,6 @@ -from supportagent.rag.vector_store import get_connection +from sqlalchemy import text + +from supportagent.db.session import engine def health() -> dict[str, str]: @@ -6,9 +8,6 @@ def health() -> dict[str, str]: def readiness() -> dict[str, str]: - conn = get_connection() - try: - conn.execute("SELECT 1").fetchone() - finally: - conn.close() + with engine.connect() as conn: + conn.execute(text("SELECT 1")) return {"status": "ready"} diff --git a/src/backend/supportagent/api/uploads.py b/src/backend/supportagent/api/uploads.py index c9bb7cc..e01550b 100644 --- a/src/backend/supportagent/api/uploads.py +++ b/src/backend/supportagent/api/uploads.py @@ -4,7 +4,7 @@ from supportagent.auth.dependencies import get_current_user from supportagent.auth.schemas import AuthUser -from supportagent.rag.vector_store import get_connection +from supportagent.db.session import SessionLocal from supportagent.storage import get_storage from supportagent.uploads import save_uploaded_image from supportagent.uploads.store import fetch_uploaded_file @@ -28,7 +28,9 @@ async def upload_image( except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error except Exception as error: - raise HTTPException(status_code=500, detail=f"Image upload failed: {error}") from error + raise HTTPException( + status_code=500, detail=f"Image upload failed: {error}" + ) from error return UploadedImageResponse( id=uploaded.id, filename=uploaded.filename, @@ -42,11 +44,9 @@ def preview_image( image_id: str, user: AuthUser = Depends(get_current_user), ) -> Response: - conn = get_connection() - try: - record = fetch_uploaded_file(conn, user_id=user.id, image_id=image_id) - finally: - conn.close() + with SessionLocal() as session: + record = fetch_uploaded_file(session, user_id=user.id, image_id=image_id) + if record is None: raise HTTPException(status_code=404, detail="Image not found.") try: @@ -55,7 +55,9 @@ def preview_image( key=record["storage_key"], ) except Exception as error: - raise HTTPException(status_code=404, detail="Image content not found.") from error + raise HTTPException( + status_code=404, detail="Image content not found." + ) from error return Response( content=content, media_type=record["content_type"], diff --git a/src/backend/supportagent/auth/service.py b/src/backend/supportagent/auth/service.py index 87aeeaf..3e42a22 100644 --- a/src/backend/supportagent/auth/service.py +++ b/src/backend/supportagent/auth/service.py @@ -1,7 +1,16 @@ from fastapi import HTTPException, status +from supportagent.db.session import SessionLocal + from .email import build_password_reset_url, send_password_reset_email, smtp_configured -from .schemas import CreatedSession, ForgotPasswordRequest, ForgotPasswordResponse, LoginRequest, RegisterRequest, ResetPasswordRequest +from .schemas import ( + CreatedSession, + ForgotPasswordRequest, + ForgotPasswordResponse, + LoginRequest, + RegisterRequest, + ResetPasswordRequest, +) from .security import ( create_password_reset_token, hash_password, @@ -10,7 +19,6 @@ verify_password, ) from .store import ( - auth_connection, create_password_reset_record, create_session, create_user, @@ -28,38 +36,26 @@ def register_user(request: RegisterRequest) -> CreatedSession: email = normalize_email(request.email) display_name = request.display_name.strip() if request.display_name else None - conn = auth_connection() - try: - if get_user_by_email(conn, email) is not None: + with SessionLocal.begin() as session: + if get_user_by_email(session, email) is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="A user with this email already exists.", ) user = create_user( - conn=conn, + session, email=email, password_hash=hash_password(request.password), display_name=display_name, ) - session = create_session(conn, user) - conn.commit() - return session - except HTTPException: - conn.rollback() - raise - except Exception: - conn.rollback() - raise - finally: - conn.close() + return create_session(session, user) def login_user(request: LoginRequest) -> CreatedSession: email = normalize_email(request.email) - conn = auth_connection() - try: - record = get_user_by_email(conn, email) + with SessionLocal.begin() as session: + record = get_user_by_email(session, email) if record is None: raise_invalid_login() @@ -67,25 +63,14 @@ def login_user(request: LoginRequest) -> CreatedSession: if not verify_password(request.password, stored_hash): raise_invalid_login() - session = create_session(conn, user) - conn.commit() - return session - except HTTPException: - conn.rollback() - raise - except Exception: - conn.rollback() - raise - finally: - conn.close() + return create_session(session, user) def request_password_reset(request: ForgotPasswordRequest) -> ForgotPasswordResponse: email = normalize_email(request.email) generic_message = "If an account exists for this email, a reset link has been sent." - conn = auth_connection() - try: - record = get_user_by_email(conn, email) + with SessionLocal.begin() as session: + record = get_user_by_email(session, email) if record is None: return ForgotPasswordResponse(ok=True, message=generic_message) @@ -93,48 +78,32 @@ def request_password_reset(request: ForgotPasswordRequest) -> ForgotPasswordResp token = create_password_reset_token() reset_url = build_password_reset_url(token) create_password_reset_record( - conn=conn, + session, user=user, token_hash=hash_password_reset_token(token), ttl_minutes=PASSWORD_RESET_TTL_MINUTES, ) email_sent = send_password_reset_email(email, reset_url) - conn.commit() return ForgotPasswordResponse( ok=True, message=generic_message, reset_url=None if email_sent or smtp_configured() else reset_url, ) - except Exception: - conn.rollback() - raise - finally: - conn.close() def reset_password(request: ResetPasswordRequest) -> None: token_hash = hash_password_reset_token(request.token) - conn = auth_connection() - try: - user_id = get_valid_password_reset_user_id(conn, token_hash) + with SessionLocal.begin() as session: + user_id = get_valid_password_reset_user_id(session, token_hash) if user_id is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Password reset link is invalid or expired.", ) - update_user_password(conn, user_id, hash_password(request.password)) - mark_password_reset_token_used(conn, token_hash) - delete_user_sessions(conn, user_id) - conn.commit() - except HTTPException: - conn.rollback() - raise - except Exception: - conn.rollback() - raise - finally: - conn.close() + update_user_password(session, user_id, hash_password(request.password)) + mark_password_reset_token_used(session, token_hash) + delete_user_sessions(session, user_id) def raise_invalid_login() -> None: diff --git a/src/backend/supportagent/auth/store.py b/src/backend/supportagent/auth/store.py index 8a172a7..c0d76af 100644 --- a/src/backend/supportagent/auth/store.py +++ b/src/backend/supportagent/auth/store.py @@ -1,244 +1,126 @@ from datetime import datetime, timedelta, timezone from uuid import uuid4 -import psycopg -from psycopg.rows import dict_row +from sqlalchemy import delete, func, select, update +from sqlalchemy.orm import Session -from supportagent.rag.vector_store import get_connection +from supportagent.db.models import PasswordResetToken, User, UserSession +from supportagent.db.session import SessionLocal from .schemas import AuthUser, CreatedSession from .security import create_session_token, hash_session_token, session_ttl_days -def create_auth_schema(conn: psycopg.Connection) -> None: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - display_name TEXT, - password_hash TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS user_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT UNIQUE NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - expires_at TIMESTAMPTZ NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS user_sessions_token_hash_idx - ON user_sessions (token_hash) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS user_sessions_user_expires_idx - ON user_sessions (user_id, expires_at DESC) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS password_reset_tokens ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT UNIQUE NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ - ) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS password_reset_tokens_token_hash_idx - ON password_reset_tokens (token_hash) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS password_reset_tokens_user_expires_idx - ON password_reset_tokens (user_id, expires_at DESC) - """ - ) - conn.commit() +def _to_auth_user(user: User) -> AuthUser: + return AuthUser(id=user.id, email=user.email, display_name=user.display_name) -def auth_connection() -> psycopg.Connection: - conn = get_connection() - create_auth_schema(conn) - return conn - - -def row_to_user(row: dict) -> AuthUser: - return AuthUser( - id=row["id"], - email=row["email"], - display_name=row["display_name"], - ) +def get_user_by_email(session: Session, email: str) -> tuple[AuthUser, str] | None: + user = session.scalar(select(User).where(User.email == email)) + if user is None: + return None + return _to_auth_user(user), user.password_hash -def get_user_by_email(conn: psycopg.Connection, email: str) -> tuple[AuthUser, str] | None: - row = conn.execute( - """ - SELECT id, email, display_name, password_hash - FROM users - WHERE email = %s - """, - (email,), - prepare=False, - ).fetchone() - if row is None: - return None - user = AuthUser(id=row[0], email=row[1], display_name=row[2]) - return user, row[3] - - -def get_user_by_id(conn: psycopg.Connection, user_id: str) -> AuthUser | None: - row = conn.execute( - """ - SELECT id, email, display_name - FROM users - WHERE id = %s - """, - (user_id,), - prepare=False, - ).fetchone() - if row is None: - return None - return AuthUser(id=row[0], email=row[1], display_name=row[2]) +def get_user_by_id(session: Session, user_id: str) -> AuthUser | None: + user = session.get(User, user_id) + return _to_auth_user(user) if user else None def create_user( - conn: psycopg.Connection, + session: Session, email: str, password_hash: str, display_name: str | None, ) -> AuthUser: - user_id = str(uuid4()) - row = conn.execute( - """ - INSERT INTO users (id, email, display_name, password_hash) - VALUES (%s, %s, %s, %s) - RETURNING id, email, display_name - """, - (user_id, email, display_name, password_hash), - ).fetchone() - return AuthUser(id=row[0], email=row[1], display_name=row[2]) - - -def create_session(conn: psycopg.Connection, user: AuthUser) -> CreatedSession: + user = User( + id=str(uuid4()), + email=email, + display_name=display_name, + password_hash=password_hash, + ) + session.add(user) + session.flush() + return _to_auth_user(user) + + +def create_session(session: Session, user: AuthUser) -> CreatedSession: token = create_session_token() expires_at = datetime.now(timezone.utc) + timedelta(days=session_ttl_days()) - conn.execute( - """ - INSERT INTO user_sessions (id, user_id, token_hash, expires_at) - VALUES (%s, %s, %s, %s) - """, - (str(uuid4()), user.id, hash_session_token(token), expires_at), + session.add( + UserSession( + id=str(uuid4()), + user_id=user.id, + token_hash=hash_session_token(token), + expires_at=expires_at, + ) ) return CreatedSession(token=token, expires_at=expires_at, user=user) def create_password_reset_record( - conn: psycopg.Connection, + session: Session, user: AuthUser, token_hash: str, ttl_minutes: int, ) -> None: - expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes) - conn.execute( - """ - INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) - VALUES (%s, %s, %s, %s) - """, - (str(uuid4()), user.id, token_hash, expires_at), + session.add( + PasswordResetToken( + id=str(uuid4()), + user_id=user.id, + token_hash=token_hash, + expires_at=datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes), + ) ) -def get_valid_password_reset_user_id(conn: psycopg.Connection, token_hash: str) -> str | None: - row = conn.execute( - """ - SELECT user_id - FROM password_reset_tokens - WHERE token_hash = %s - AND used_at IS NULL - AND expires_at > now() - """, - (token_hash,), - prepare=False, - ).fetchone() - return row[0] if row else None - - -def mark_password_reset_token_used(conn: psycopg.Connection, token_hash: str) -> None: - conn.execute( - """ - UPDATE password_reset_tokens - SET used_at = now() - WHERE token_hash = %s - """, - (token_hash,), +def get_valid_password_reset_user_id(session: Session, token_hash: str) -> str | None: + return session.scalar( + select(PasswordResetToken.user_id).where( + PasswordResetToken.token_hash == token_hash, + PasswordResetToken.used_at.is_(None), + PasswordResetToken.expires_at > func.now(), + ) ) -def update_user_password(conn: psycopg.Connection, user_id: str, password_hash: str) -> None: - conn.execute( - """ - UPDATE users - SET password_hash = %s, - updated_at = now() - WHERE id = %s - """, - (password_hash, user_id), +def mark_password_reset_token_used(session: Session, token_hash: str) -> None: + session.execute( + update(PasswordResetToken) + .where(PasswordResetToken.token_hash == token_hash) + .values(used_at=func.now()) ) -def delete_user_sessions(conn: psycopg.Connection, user_id: str) -> None: - conn.execute( - "DELETE FROM user_sessions WHERE user_id = %s", - (user_id,), +def update_user_password(session: Session, user_id: str, password_hash: str) -> None: + session.execute( + update(User) + .where(User.id == user_id) + .values(password_hash=password_hash, updated_at=func.now()) ) +def delete_user_sessions(session: Session, user_id: str) -> None: + session.execute(delete(UserSession).where(UserSession.user_id == user_id)) + + def get_user_by_session_token(token: str) -> AuthUser | None: - conn = auth_connection() - try: - token_hash = hash_session_token(token) - with conn.cursor(row_factory=dict_row) as cursor: - row = cursor.execute( - """ - SELECT users.id, users.email, users.display_name - FROM user_sessions - JOIN users ON users.id = user_sessions.user_id - WHERE user_sessions.token_hash = %s - AND user_sessions.expires_at > now() - """, - (token_hash,), - ).fetchone() - if row is None: - return None - return row_to_user(row) - finally: - conn.close() + with SessionLocal() as session: + user = session.scalar( + select(User) + .join(UserSession, UserSession.user_id == User.id) + .where( + UserSession.token_hash == hash_session_token(token), + UserSession.expires_at > func.now(), + ) + ) + return _to_auth_user(user) if user else None def delete_session_token(token: str) -> None: - conn = auth_connection() - try: - conn.execute( - "DELETE FROM user_sessions WHERE token_hash = %s", - (hash_session_token(token),), + with SessionLocal.begin() as session: + session.execute( + delete(UserSession).where( + UserSession.token_hash == hash_session_token(token) + ) ) - conn.commit() - finally: - conn.close() diff --git a/src/backend/supportagent/claims/__init__.py b/src/backend/supportagent/claims/__init__.py index 0253eb2..510b979 100644 --- a/src/backend/supportagent/claims/__init__.py +++ b/src/backend/supportagent/claims/__init__.py @@ -11,8 +11,8 @@ ProposedAction, ) from .service import ( - approve_action, apply_human_claim_decision, + approve_action, create_claim, create_claim_document, create_proposed_action, @@ -23,7 +23,6 @@ start_claim_review_run, submit_claim_for_review, ) -from .store import ensure_claim_schema __all__ = [ "ActionStatus", @@ -41,7 +40,6 @@ "create_claim", "create_claim_document", "create_proposed_action", - "ensure_claim_schema", "get_claim", "get_claim_review_run", "list_claims", diff --git a/src/backend/supportagent/claims/service.py b/src/backend/supportagent/claims/service.py index 9980a77..a37e595 100644 --- a/src/backend/supportagent/claims/service.py +++ b/src/backend/supportagent/claims/service.py @@ -1,6 +1,7 @@ -from typing import Any from uuid import uuid4 +from sqlalchemy.orm import Session + from supportagent.claims.schemas import ( Claim, ClaimDetail, @@ -39,7 +40,7 @@ update_claim_status, update_review_run, ) -from supportagent.rag.vector_store import get_connection +from supportagent.db.session import SessionLocal class ClaimNotFoundError(LookupError): @@ -55,10 +56,9 @@ class ClaimStateConflictError(ValueError): def create_claim(owner_user_id: str, request: CreateClaimRequest) -> Claim: - conn = get_connection() - try: + with SessionLocal.begin() as session: claim = insert_claim( - conn, + session, { "id": str(uuid4()), "owner_user_id": owner_user_id, @@ -72,40 +72,31 @@ def create_claim(owner_user_id: str, request: CreateClaimRequest) -> Claim: "status": "DRAFT", }, ) - add_audit_event(conn, claim.id, owner_user_id, "CLAIM_CREATED", {"status": claim.status}) - conn.commit() + add_audit_event(session, claim.id, owner_user_id, "CLAIM_CREATED", {"status": claim.status}) return claim - finally: - conn.close() def list_claims(owner_user_id: str) -> list[Claim]: - conn = get_connection() - try: - return fetch_claims(conn, owner_user_id) - finally: - conn.close() + with SessionLocal() as session: + return fetch_claims(session, owner_user_id) def get_claim(claim_id: str, owner_user_id: str) -> ClaimDetail: - conn = get_connection() - try: - claim = fetch_claim(conn, claim_id, owner_user_id) + with SessionLocal() as session: + claim = fetch_claim(session, claim_id, owner_user_id) if claim is None: raise ClaimNotFoundError("Claim not found.") return ClaimDetail( claim=claim, - documents=fetch_documents(conn, claim_id), - proposed_actions=fetch_actions(conn, claim_id), - latest_review_run=fetch_latest_review_run(conn, claim_id, owner_user_id), - audit_events=fetch_audit_events(conn, claim_id), + documents=fetch_documents(session, claim_id), + proposed_actions=fetch_actions(session, claim_id), + latest_review_run=fetch_latest_review_run(session, claim_id, owner_user_id), + audit_events=fetch_audit_events(session, claim_id), ) - finally: - conn.close() def _transition_claim( - conn: Any, + session: Session, claim: Claim, target_status: str, actor_user_id: str, @@ -118,9 +109,9 @@ def _transition_claim( validate_claim_transition(claim.status, target_status) except ValueError as error: raise ClaimStateConflictError(str(error)) from error - updated = update_claim_status(conn, claim.id, target_status) + updated = update_claim_status(session, claim.id, target_status) add_audit_event( - conn, + session, claim.id, actor_user_id, "CLAIM_STATUS_CHANGED", @@ -132,28 +123,27 @@ def _transition_claim( def submit_claim_for_review(claim_id: str, owner_user_id: str) -> ClaimSubmissionResponse: from supportagent.claims.document_rules import completed_document_types, missing_documents_for_claim - conn = get_connection() - try: - claim = fetch_claim(conn, claim_id, owner_user_id) + with SessionLocal.begin() as session: + claim = fetch_claim(session, claim_id, owner_user_id) if claim is None: raise ClaimNotFoundError("Claim not found.") if claim.status not in {"DRAFT", "DOCUMENTS_PENDING", "NEEDS_INFORMATION"}: raise ClaimStateConflictError( f"Claim in status {claim.status} cannot be submitted for review." ) - documents = fetch_documents(conn, claim_id) + documents = fetch_documents(session, claim_id) present = completed_document_types(documents) missing = missing_documents_for_claim(claim, documents) target_status = "DOCUMENTS_PENDING" if missing else "READY_FOR_REVIEW" updated = _transition_claim( - conn, + session, claim, target_status, owner_user_id, reason="Submission document validation completed.", ) add_audit_event( - conn, + session, claim_id, owner_user_id, "CLAIM_SUBMISSION_VALIDATED", @@ -163,39 +153,35 @@ def submit_claim_for_review(claim_id: str, owner_user_id: str) -> ClaimSubmissio "target_status": target_status, }, ) - conn.commit() return ClaimSubmissionResponse( claim=updated, present_documents=present, missing_documents=missing, ) - finally: - conn.close() def start_claim_review_run(claim_id: str, owner_user_id: str) -> ClaimReviewRun: - conn = get_connection() - try: - claim = fetch_claim(conn, claim_id, owner_user_id) + with SessionLocal.begin() as session: + claim = fetch_claim(session, claim_id, owner_user_id) if claim is None: raise ClaimNotFoundError("Claim not found.") if claim.status != "READY_FOR_REVIEW": raise ClaimStateConflictError( f"Claim in status {claim.status} is not ready for review." ) - latest = fetch_latest_review_run(conn, claim_id, owner_user_id) + latest = fetch_latest_review_run(session, claim_id, owner_user_id) if latest and latest.status in {"QUEUED", "RUNNING"}: raise ClaimStateConflictError("A review is already running for this claim.") run_id = str(uuid4()) _transition_claim( - conn, + session, claim, "UNDER_REVIEW", owner_user_id, reason="Claim review started.", ) run = insert_review_run( - conn, + session, run_id, claim_id, owner_user_id, @@ -203,29 +189,23 @@ def start_claim_review_run(claim_id: str, owner_user_id: str) -> ClaimReviewRun: current_step="LOAD_CLAIM", ) add_audit_event( - conn, + session, claim_id, owner_user_id, "CLAIM_REVIEW_STARTED", {"run_id": run_id}, ) - conn.commit() return run - finally: - conn.close() def get_claim_review_run( claim_id: str, run_id: str, owner_user_id: str ) -> ClaimReviewRun: - conn = get_connection() - try: - run = fetch_review_run_for_owner(conn, run_id, claim_id, owner_user_id) + with SessionLocal() as session: + run = fetch_review_run_for_owner(session, run_id, claim_id, owner_user_id) if run is None: raise ClaimNotFoundError("Claim review run not found.") return run - finally: - conn.close() def record_claim_review_progress( @@ -234,28 +214,23 @@ def record_claim_review_progress( owner_user_id: str, step: str, ) -> None: - conn = get_connection() - try: - update_review_run(conn, run_id, status="RUNNING", current_step=step) + with SessionLocal.begin() as session: + update_review_run(session, run_id, status="RUNNING", current_step=step) add_audit_event( - conn, + session, claim_id, owner_user_id, "CLAIM_REVIEW_PROGRESS", {"run_id": run_id, "step": step}, ) - conn.commit() - finally: - conn.close() def complete_claim_review_run( result: ClaimReviewResponse, owner_user_id: str, ) -> ClaimReviewRun: - conn = get_connection() - try: - claim = fetch_claim(conn, result.claim_id, owner_user_id) + with SessionLocal.begin() as session: + claim = fetch_claim(session, result.claim_id, owner_user_id) if claim is None: raise ClaimNotFoundError("Claim not found.") target_status = ( @@ -264,14 +239,14 @@ def complete_claim_review_run( else "READY_FOR_DECISION" ) _transition_claim( - conn, + session, claim, target_status, owner_user_id, reason="Claim review completed.", ) run = update_review_run( - conn, + session, result.run_id, status="SUCCEEDED", current_step="COMPLETED", @@ -279,7 +254,7 @@ def complete_claim_review_run( complete=True, ) add_audit_event( - conn, + session, result.claim_id, owner_user_id, "CLAIM_REVIEW_COMPLETED", @@ -290,10 +265,7 @@ def complete_claim_review_run( "target_status": target_status, }, ) - conn.commit() return run - finally: - conn.close() def fail_claim_review_run( @@ -302,19 +274,18 @@ def fail_claim_review_run( owner_user_id: str, error: str, ) -> None: - conn = get_connection() - try: - claim = fetch_claim(conn, claim_id, owner_user_id) + with SessionLocal.begin() as session: + claim = fetch_claim(session, claim_id, owner_user_id) if claim is not None and claim.status == "UNDER_REVIEW": _transition_claim( - conn, + session, claim, "READY_FOR_REVIEW", owner_user_id, reason="Claim review failed and can be retried.", ) update_review_run( - conn, + session, run_id, status="FAILED", current_step="FAILED", @@ -322,15 +293,12 @@ def fail_claim_review_run( complete=True, ) add_audit_event( - conn, + session, claim_id, owner_user_id, "CLAIM_REVIEW_FAILED", {"run_id": run_id, "error": error[:500]}, ) - conn.commit() - finally: - conn.close() def apply_claim_next_step( @@ -338,9 +306,8 @@ def apply_claim_next_step( owner_user_id: str, request: ClaimNextStepRequest, ) -> Claim: - conn = get_connection() - try: - claim = fetch_claim(conn, claim_id, owner_user_id) + with SessionLocal.begin() as session: + claim = fetch_claim(session, claim_id, owner_user_id) if claim is None: raise ClaimNotFoundError("Claim not found.") if claim.status != "READY_FOR_DECISION": @@ -353,14 +320,14 @@ def apply_claim_next_step( else "EXPERT_REVIEW_REQUIRED" ) updated = _transition_claim( - conn, + session, claim, target_status, owner_user_id, reason=request.comment or request.next_step, ) add_audit_event( - conn, + session, claim_id, owner_user_id, "CLAIM_NEXT_STEP_SELECTED", @@ -369,10 +336,7 @@ def apply_claim_next_step( "comment": request.comment, }, ) - conn.commit() return updated - finally: - conn.close() def apply_human_claim_decision( @@ -380,9 +344,8 @@ def apply_human_claim_decision( owner_user_id: str, request: ClaimDecisionRequest, ) -> Claim: - conn = get_connection() - try: - claim = fetch_claim(conn, claim_id, owner_user_id) + with SessionLocal.begin() as session: + claim = fetch_claim(session, claim_id, owner_user_id) if claim is None: raise ClaimNotFoundError("Claim not found.") if claim.status != "READY_FOR_DECISION": @@ -391,14 +354,14 @@ def apply_human_claim_decision( ) target_status = "APPROVED" if request.decision == "APPROVE" else "REJECTED" updated = _transition_claim( - conn, + session, claim, target_status, owner_user_id, reason=request.reason or "Approved after human review.", ) add_audit_event( - conn, + session, claim_id, owner_user_id, "CLAIM_HUMAN_DECISION_RECORDED", @@ -408,21 +371,17 @@ def apply_human_claim_decision( "decision_source": "HUMAN", }, ) - conn.commit() return updated - finally: - conn.close() def create_claim_document( claim_id: str, owner_user_id: str, request: CreateClaimDocumentRequest ) -> ClaimDocument: - conn = get_connection() - try: - if fetch_claim(conn, claim_id, owner_user_id) is None: + with SessionLocal.begin() as session: + if fetch_claim(session, claim_id, owner_user_id) is None: raise ClaimNotFoundError("Claim not found.") document = insert_document( - conn, + session, { "id": str(uuid4()), "claim_id": claim_id, @@ -430,24 +389,20 @@ def create_claim_document( }, ) add_audit_event( - conn, claim_id, owner_user_id, "CLAIM_DOCUMENT_REGISTERED", + session, claim_id, owner_user_id, "CLAIM_DOCUMENT_REGISTERED", {"document_id": document.id, "document_type": document.document_type}, ) - conn.commit() return document - finally: - conn.close() def create_proposed_action( claim_id: str, owner_user_id: str, request: CreateProposedActionRequest ) -> ProposedAction: - conn = get_connection() - try: - if fetch_claim(conn, claim_id, owner_user_id) is None: + with SessionLocal.begin() as session: + if fetch_claim(session, claim_id, owner_user_id) is None: raise ClaimNotFoundError("Claim not found.") action = insert_action( - conn, + session, { "id": str(uuid4()), "claim_id": claim_id, @@ -458,36 +413,29 @@ def create_proposed_action( }, ) add_audit_event( - conn, claim_id, owner_user_id, "ACTION_PROPOSED", + session, claim_id, owner_user_id, "ACTION_PROPOSED", {"action_id": action.id, "action_type": action.action_type, "risk_level": action.risk_level}, ) - conn.commit() return action - finally: - conn.close() def _decide_action( claim_id: str, action_id: str, owner_user_id: str, decision: str, comment: str | None ) -> ProposedAction: - conn = get_connection() - try: - action = fetch_action_for_owner(conn, action_id, claim_id, owner_user_id, for_update=True) + with SessionLocal.begin() as session: + action = fetch_action_for_owner(session, action_id, claim_id, owner_user_id, for_update=True) if action is None: raise ClaimNotFoundError("Proposed action not found.") try: next_status = validate_action_decision(action.status, decision) except ValueError as error: raise ClaimActionConflictError(str(error)) from error - updated = record_action_decision(conn, action_id, owner_user_id, next_status, comment) + updated = record_action_decision(session, action_id, owner_user_id, next_status, comment) add_audit_event( - conn, claim_id, owner_user_id, f"ACTION_{next_status}", + session, claim_id, owner_user_id, f"ACTION_{next_status}", {"action_id": action_id, "comment": comment}, ) - conn.commit() return updated - finally: - conn.close() def approve_action( diff --git a/src/backend/supportagent/claims/store.py b/src/backend/supportagent/claims/store.py index add2864..0fba527 100644 --- a/src/backend/supportagent/claims/store.py +++ b/src/backend/supportagent/claims/store.py @@ -1,7 +1,7 @@ from typing import Any -import psycopg -from psycopg.types.json import Json, Jsonb +from sqlalchemy import func, select, update +from sqlalchemy.orm import Session from supportagent.claims.schemas import ( Claim, @@ -11,358 +11,270 @@ ProposedAction, ) - -def ensure_claim_schema() -> None: - from supportagent.rag.vector_store import get_connection - - conn = get_connection() - try: - create_claim_schema(conn) - finally: - conn.close() - - -def create_claim_schema(conn: psycopg.Connection) -> None: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS claims ( - id TEXT PRIMARY KEY, - owner_user_id TEXT NOT NULL, - policy_id TEXT NOT NULL, - product_line TEXT NOT NULL DEFAULT 'unknown', - policy_version TEXT, - jurisdiction TEXT NOT NULL DEFAULT 'DE', - customer_reference TEXT NOT NULL, - claim_type TEXT NOT NULL, - incident_date TEXT, - status TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute("ALTER TABLE claims ADD COLUMN IF NOT EXISTS product_line TEXT NOT NULL DEFAULT 'unknown'") - conn.execute("ALTER TABLE claims ADD COLUMN IF NOT EXISTS policy_version TEXT") - conn.execute("ALTER TABLE claims ADD COLUMN IF NOT EXISTS jurisdiction TEXT NOT NULL DEFAULT 'DE'") - conn.execute( - """ - CREATE TABLE IF NOT EXISTS claim_documents ( - id TEXT PRIMARY KEY, - claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, - uploaded_file_id TEXT, - document_type TEXT NOT NULL, - filename TEXT NOT NULL, - extraction_status TEXT NOT NULL DEFAULT 'PENDING', - extracted_fields JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS proposed_actions ( - id TEXT PRIMARY KEY, - claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, - run_id TEXT, - action_type TEXT NOT NULL, - tool_server TEXT NOT NULL, - tool_name TEXT NOT NULL, - arguments JSONB NOT NULL DEFAULT '{}'::jsonb, - reason TEXT NOT NULL, - risk_level TEXT NOT NULL, - status TEXT NOT NULL, - proposed_by TEXT NOT NULL, - approved_by TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS claim_action_approvals ( - id BIGSERIAL PRIMARY KEY, - proposed_action_id TEXT NOT NULL REFERENCES proposed_actions(id) ON DELETE CASCADE, - actor_user_id TEXT NOT NULL, - decision TEXT NOT NULL CHECK (decision IN ('APPROVED', 'REJECTED')), - comment TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS claim_audit_events ( - id BIGSERIAL PRIMARY KEY, - claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, - actor_user_id TEXT NOT NULL, - event_type TEXT NOT NULL, - payload JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS claim_review_runs ( - id TEXT PRIMARY KEY, - claim_id TEXT NOT NULL REFERENCES claims(id) ON DELETE CASCADE, - owner_user_id TEXT NOT NULL, - status TEXT NOT NULL, - current_step TEXT NOT NULL, - result JSONB, - error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - completed_at TIMESTAMPTZ - ) - """ - ) - conn.execute("CREATE INDEX IF NOT EXISTS claims_owner_updated_idx ON claims (owner_user_id, updated_at DESC)") - conn.execute("CREATE INDEX IF NOT EXISTS claim_documents_claim_idx ON claim_documents (claim_id, created_at)") - conn.execute("CREATE INDEX IF NOT EXISTS proposed_actions_claim_idx ON proposed_actions (claim_id, created_at DESC)") - conn.execute("CREATE INDEX IF NOT EXISTS claim_audit_claim_idx ON claim_audit_events (claim_id, created_at)") - conn.execute("CREATE INDEX IF NOT EXISTS claim_review_runs_claim_idx ON claim_review_runs (claim_id, created_at DESC)") - conn.commit() +from supportagent.db.models import Claim as ClaimRow +from supportagent.db.models import ClaimActionApproval +from supportagent.db.models import ClaimAuditEvent as ClaimAuditEventRow +from supportagent.db.models import ClaimDocument as ClaimDocumentRow +from supportagent.db.models import ClaimReviewRun as ClaimReviewRunRow +from supportagent.db.models import ProposedAction as ProposedActionRow -def _claim(row: tuple[Any, ...]) -> Claim: +def _claim(row: ClaimRow) -> Claim: return Claim( - id=row[0], owner_user_id=row[1], policy_id=row[2], customer_reference=row[3], - claim_type=row[4], incident_date=row[5], status=row[6], - created_at=row[7].isoformat(), updated_at=row[8].isoformat(), - product_line=row[9], policy_version=row[10], jurisdiction=row[11], + id=row.id, + owner_user_id=row.owner_user_id, + policy_id=row.policy_id, + customer_reference=row.customer_reference, + claim_type=row.claim_type, + incident_date=row.incident_date, + status=row.status, + created_at=row.created_at.isoformat(), + updated_at=row.updated_at.isoformat(), + product_line=row.product_line, + policy_version=row.policy_version, + jurisdiction=row.jurisdiction, ) -def _document(row: tuple[Any, ...]) -> ClaimDocument: +def _document(row: ClaimDocumentRow) -> ClaimDocument: return ClaimDocument( - id=row[0], claim_id=row[1], uploaded_file_id=row[2], document_type=row[3], - filename=row[4], extraction_status=row[5], extracted_fields=row[6] or {}, - created_at=row[7].isoformat(), + id=row.id, + claim_id=row.claim_id, + uploaded_file_id=row.uploaded_file_id, + document_type=row.document_type, + filename=row.filename, + extraction_status=row.extraction_status, + extracted_fields=row.extracted_fields or {}, + created_at=row.created_at.isoformat(), ) -def _action(row: tuple[Any, ...]) -> ProposedAction: +def _action(row: ProposedActionRow) -> ProposedAction: return ProposedAction( - id=row[0], claim_id=row[1], run_id=row[2], action_type=row[3], - tool_server=row[4], tool_name=row[5], arguments=row[6] or {}, reason=row[7], - risk_level=row[8], status=row[9], proposed_by=row[10], approved_by=row[11], - approved_at=row[12].isoformat() if row[12] else None, - created_at=row[13].isoformat(), updated_at=row[14].isoformat(), + id=row.id, + claim_id=row.claim_id, + run_id=row.run_id, + action_type=row.action_type, + tool_server=row.tool_server, + tool_name=row.tool_name, + arguments=row.arguments or {}, + reason=row.reason, + risk_level=row.risk_level, + status=row.status, + proposed_by=row.proposed_by, + approved_by=row.approved_by, + approved_at=row.approved_at.isoformat() if row.approved_at else None, + created_at=row.created_at.isoformat(), + updated_at=row.updated_at.isoformat(), ) -def _review_run(row: tuple[Any, ...]) -> ClaimReviewRun: +def _review_run(row: ClaimReviewRunRow) -> ClaimReviewRun: return ClaimReviewRun( - id=row[0], - claim_id=row[1], - status=row[2], - current_step=row[3], - result=row[4], - error=row[5], - created_at=row[6].isoformat(), - updated_at=row[7].isoformat(), - completed_at=row[8].isoformat() if row[8] else None, + id=row.id, + claim_id=row.claim_id, + status=row.status, + current_step=row.current_step, + result=row.result, + error=row.error, + created_at=row.created_at.isoformat(), + updated_at=row.updated_at.isoformat(), + completed_at=row.completed_at.isoformat() if row.completed_at else None, ) -def _audit_event(row: tuple[Any, ...]) -> ClaimAuditEvent: +def _audit_event(row: ClaimAuditEventRow) -> ClaimAuditEvent: return ClaimAuditEvent( - id=row[0], - event_type=row[1], - payload=row[2] or {}, - actor_user_id=row[3], - created_at=row[4].isoformat(), + id=row.id, + event_type=row.event_type, + payload=row.payload or {}, + actor_user_id=row.actor_user_id, + created_at=row.created_at.isoformat(), ) -CLAIM_SELECT = """ - SELECT id, owner_user_id, policy_id, customer_reference, claim_type, - incident_date, status, created_at, updated_at, - product_line, policy_version, jurisdiction - FROM claims -""" - -ACTION_SELECT = """ - SELECT proposed_actions.id, proposed_actions.claim_id, proposed_actions.run_id, - proposed_actions.action_type, proposed_actions.tool_server, - proposed_actions.tool_name, proposed_actions.arguments, - proposed_actions.reason, proposed_actions.risk_level, - proposed_actions.status, proposed_actions.proposed_by, - proposed_actions.approved_by, proposed_actions.approved_at, - proposed_actions.created_at, proposed_actions.updated_at - FROM proposed_actions -""" - -REVIEW_RUN_SELECT = """ - SELECT id, claim_id, status, current_step, result, error, - created_at, updated_at, completed_at - FROM claim_review_runs -""" - - -def insert_claim(conn: psycopg.Connection, values: dict[str, Any]) -> Claim: - row = conn.execute( - """ - INSERT INTO claims ( - id, owner_user_id, policy_id, customer_reference, claim_type, - incident_date, status, product_line, policy_version, jurisdiction - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, owner_user_id, policy_id, customer_reference, claim_type, - incident_date, status, created_at, updated_at, - product_line, policy_version, jurisdiction - """, - (values["id"], values["owner_user_id"], values["policy_id"], values["customer_reference"], - values["claim_type"], values.get("incident_date"), values["status"], - values["product_line"], values.get("policy_version"), values["jurisdiction"]), - ).fetchone() +def insert_claim(session: Session, values: dict[str, Any]) -> Claim: + row = ClaimRow( + id=values["id"], + owner_user_id=values["owner_user_id"], + policy_id=values["policy_id"], + customer_reference=values["customer_reference"], + claim_type=values["claim_type"], + incident_date=values.get("incident_date"), + status=values["status"], + product_line=values["product_line"], + policy_version=values.get("policy_version"), + jurisdiction=values["jurisdiction"], + ) + session.add(row) + session.flush() return _claim(row) -def fetch_claim(conn: psycopg.Connection, claim_id: str, owner_user_id: str) -> Claim | None: - row = conn.execute(CLAIM_SELECT + " WHERE id = %s AND owner_user_id = %s", (claim_id, owner_user_id)).fetchone() +def fetch_claim(session: Session, claim_id: str, owner_user_id: str) -> Claim | None: + row = session.scalar( + select(ClaimRow).where( + ClaimRow.id == claim_id, ClaimRow.owner_user_id == owner_user_id + ) + ) return _claim(row) if row else None -def fetch_claims(conn: psycopg.Connection, owner_user_id: str) -> list[Claim]: - rows = conn.execute(CLAIM_SELECT + " WHERE owner_user_id = %s ORDER BY updated_at DESC", (owner_user_id,)).fetchall() +def fetch_claims(session: Session, owner_user_id: str) -> list[Claim]: + rows = session.scalars( + select(ClaimRow) + .where(ClaimRow.owner_user_id == owner_user_id) + .order_by(ClaimRow.updated_at.desc()) + ) return [_claim(row) for row in rows] -def update_claim_status(conn: psycopg.Connection, claim_id: str, status: str) -> Claim: - row = conn.execute( - """ - UPDATE claims - SET status = %s, updated_at = now() - WHERE id = %s - RETURNING id, owner_user_id, policy_id, customer_reference, claim_type, - incident_date, status, created_at, updated_at, - product_line, policy_version, jurisdiction - """, - (status, claim_id), - ).fetchone() +def update_claim_status(session: Session, claim_id: str, status: str) -> Claim: + row = session.scalar( + update(ClaimRow) + .where(ClaimRow.id == claim_id) + .values(status=status, updated_at=func.now()) + .returning(ClaimRow) + ) return _claim(row) -def insert_document(conn: psycopg.Connection, values: dict[str, Any]) -> ClaimDocument: - row = conn.execute( - """ - INSERT INTO claim_documents ( - id, claim_id, uploaded_file_id, document_type, filename, extraction_status, extracted_fields - ) VALUES (%s, %s, %s, %s, %s, %s, %s) - RETURNING id, claim_id, uploaded_file_id, document_type, filename, - extraction_status, extracted_fields, created_at - """, - (values["id"], values["claim_id"], values.get("uploaded_file_id"), values["document_type"], - values["filename"], values["extraction_status"], Json(values.get("extracted_fields", {}))), - ).fetchone() +def insert_document(session: Session, values: dict[str, Any]) -> ClaimDocument: + row = ClaimDocumentRow( + id=values["id"], + claim_id=values["claim_id"], + uploaded_file_id=values.get("uploaded_file_id"), + document_type=values["document_type"], + filename=values["filename"], + extraction_status=values["extraction_status"], + extracted_fields=values.get("extracted_fields", {}), + ) + session.add(row) + session.flush() return _document(row) -def fetch_documents(conn: psycopg.Connection, claim_id: str) -> list[ClaimDocument]: - rows = conn.execute( - """ - SELECT id, claim_id, uploaded_file_id, document_type, filename, - extraction_status, extracted_fields, created_at - FROM claim_documents WHERE claim_id = %s ORDER BY created_at - """, - (claim_id,), - ).fetchall() +def fetch_documents(session: Session, claim_id: str) -> list[ClaimDocument]: + rows = session.scalars( + select(ClaimDocumentRow) + .where(ClaimDocumentRow.claim_id == claim_id) + .order_by(ClaimDocumentRow.created_at) + ) return [_document(row) for row in rows] -def insert_action(conn: psycopg.Connection, values: dict[str, Any]) -> ProposedAction: - row = conn.execute( - """ - INSERT INTO proposed_actions ( - id, claim_id, run_id, action_type, tool_server, tool_name, arguments, - reason, risk_level, status, proposed_by - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, claim_id, run_id, action_type, tool_server, tool_name, arguments, - reason, risk_level, status, proposed_by, approved_by, approved_at, - created_at, updated_at - """, - (values["id"], values["claim_id"], values.get("run_id"), values["action_type"], - values["tool_server"], values["tool_name"], Json(values.get("arguments", {})), - values["reason"], values["risk_level"], values["status"], values["proposed_by"]), - ).fetchone() +def insert_action(session: Session, values: dict[str, Any]) -> ProposedAction: + row = ProposedActionRow( + id=values["id"], + claim_id=values["claim_id"], + run_id=values.get("run_id"), + action_type=values["action_type"], + tool_server=values["tool_server"], + tool_name=values["tool_name"], + arguments=values.get("arguments", {}), + reason=values["reason"], + risk_level=values["risk_level"], + status=values["status"], + proposed_by=values["proposed_by"], + ) + session.add(row) + session.flush() return _action(row) -def fetch_actions(conn: psycopg.Connection, claim_id: str) -> list[ProposedAction]: - rows = conn.execute(ACTION_SELECT + " WHERE claim_id = %s ORDER BY created_at DESC", (claim_id,)).fetchall() +def fetch_actions(session: Session, claim_id: str) -> list[ProposedAction]: + rows = session.scalars( + select(ProposedActionRow) + .where(ProposedActionRow.claim_id == claim_id) + .order_by(ProposedActionRow.created_at.desc()) + ) return [_action(row) for row in rows] def fetch_action_for_owner( - conn: psycopg.Connection, action_id: str, claim_id: str, owner_user_id: str, *, for_update: bool = False + session: Session, + action_id: str, + claim_id: str, + owner_user_id: str, + *, + for_update: bool = False, ) -> ProposedAction | None: - suffix = " FOR UPDATE" if for_update else "" - row = conn.execute( - ACTION_SELECT + " JOIN claims ON claims.id = proposed_actions.claim_id " - "WHERE proposed_actions.id = %s AND proposed_actions.claim_id = %s AND claims.owner_user_id = %s" + suffix, - (action_id, claim_id, owner_user_id), - ).fetchone() + stmt = ( + select(ProposedActionRow) + .join(ClaimRow, ClaimRow.id == ProposedActionRow.claim_id) + .where( + ProposedActionRow.id == action_id, + ProposedActionRow.claim_id == claim_id, + ClaimRow.owner_user_id == owner_user_id, + ) + ) + if for_update: + stmt = stmt.with_for_update() + row = session.scalar(stmt) return _action(row) if row else None def record_action_decision( - conn: psycopg.Connection, action_id: str, actor_user_id: str, decision: str, comment: str | None + session: Session, + action_id: str, + actor_user_id: str, + decision: str, + comment: str | None, ) -> ProposedAction: - conn.execute( - """ - INSERT INTO claim_action_approvals (proposed_action_id, actor_user_id, decision, comment) - VALUES (%s, %s, %s, %s) - """, - (action_id, actor_user_id, decision, comment), + session.add( + ClaimActionApproval( + proposed_action_id=action_id, + actor_user_id=actor_user_id, + decision=decision, + comment=comment, + ) + ) + approved = decision == "APPROVED" + row = session.scalar( + update(ProposedActionRow) + .where(ProposedActionRow.id == action_id) + .values( + status=decision, + approved_by=actor_user_id if approved else None, + approved_at=func.now() if approved else None, + updated_at=func.now(), + ) + .returning(ProposedActionRow) ) - row = conn.execute( - """ - UPDATE proposed_actions - SET status = %s, - approved_by = CASE WHEN %s = 'APPROVED' THEN %s ELSE NULL END, - approved_at = CASE WHEN %s = 'APPROVED' THEN now() ELSE NULL END, - updated_at = now() - WHERE id = %s - RETURNING id, claim_id, run_id, action_type, tool_server, tool_name, arguments, - reason, risk_level, status, proposed_by, approved_by, approved_at, - created_at, updated_at - """, - (decision, decision, actor_user_id, decision, action_id), - ).fetchone() return _action(row) def add_audit_event( - conn: psycopg.Connection, claim_id: str, actor_user_id: str, event_type: str, payload: dict[str, Any] + session: Session, + claim_id: str, + actor_user_id: str, + event_type: str, + payload: dict[str, Any], ) -> None: - conn.execute( - "INSERT INTO claim_audit_events (claim_id, actor_user_id, event_type, payload) VALUES (%s, %s, %s, %s)", - (claim_id, actor_user_id, event_type, Json(payload)), + session.add( + ClaimAuditEventRow( + claim_id=claim_id, + actor_user_id=actor_user_id, + event_type=event_type, + payload=payload, + ) ) def fetch_audit_events( - conn: psycopg.Connection, claim_id: str, *, limit: int = 50 + session: Session, claim_id: str, *, limit: int = 50 ) -> list[ClaimAuditEvent]: - rows = conn.execute( - """ - SELECT id, event_type, payload, actor_user_id, created_at - FROM claim_audit_events - WHERE claim_id = %s - ORDER BY created_at DESC - LIMIT %s - """, - (claim_id, limit), - ).fetchall() + rows = session.scalars( + select(ClaimAuditEventRow) + .where(ClaimAuditEventRow.claim_id == claim_id) + .order_by(ClaimAuditEventRow.created_at.desc()) + .limit(limit) + ) return [_audit_event(row) for row in rows] def insert_review_run( - conn: psycopg.Connection, + session: Session, run_id: str, claim_id: str, owner_user_id: str, @@ -370,22 +282,20 @@ def insert_review_run( status: str = "QUEUED", current_step: str = "QUEUED", ) -> ClaimReviewRun: - row = conn.execute( - """ - INSERT INTO claim_review_runs ( - id, claim_id, owner_user_id, status, current_step - ) - VALUES (%s, %s, %s, %s, %s) - RETURNING id, claim_id, status, current_step, result, error, - created_at, updated_at, completed_at - """, - (run_id, claim_id, owner_user_id, status, current_step), - ).fetchone() + row = ClaimReviewRunRow( + id=run_id, + claim_id=claim_id, + owner_user_id=owner_user_id, + status=status, + current_step=current_step, + ) + session.add(row) + session.flush() return _review_run(row) def update_review_run( - conn: psycopg.Connection, + session: Session, run_id: str, *, status: str | None = None, @@ -394,62 +304,52 @@ def update_review_run( error: str | None = None, complete: bool = False, ) -> ClaimReviewRun: - row = conn.execute( - """ - UPDATE claim_review_runs - SET status = COALESCE(%s, status), - current_step = COALESCE(%s, current_step), - result = COALESCE(%s, result), - error = %s, - updated_at = now(), - completed_at = CASE WHEN %s THEN now() ELSE completed_at END - WHERE id = %s - RETURNING id, claim_id, status, current_step, result, error, - created_at, updated_at, completed_at - """, - ( - status, - current_step, - Jsonb(result) if result is not None else None, - error, - complete, - run_id, - ), - ).fetchone() + values: dict[str, Any] = {"error": error, "updated_at": func.now()} + if status is not None: + values["status"] = status + if current_step is not None: + values["current_step"] = current_step + if result is not None: + values["result"] = result + if complete: + values["completed_at"] = func.now() + row = session.scalar( + update(ClaimReviewRunRow) + .where(ClaimReviewRunRow.id == run_id) + .values(**values) + .returning(ClaimReviewRunRow) + ) return _review_run(row) def fetch_review_run_for_owner( - conn: psycopg.Connection, + session: Session, run_id: str, claim_id: str, owner_user_id: str, ) -> ClaimReviewRun | None: - row = conn.execute( - REVIEW_RUN_SELECT - + """ - WHERE id = %s - AND claim_id = %s - AND owner_user_id = %s - """, - (run_id, claim_id, owner_user_id), - ).fetchone() + row = session.scalar( + select(ClaimReviewRunRow).where( + ClaimReviewRunRow.id == run_id, + ClaimReviewRunRow.claim_id == claim_id, + ClaimReviewRunRow.owner_user_id == owner_user_id, + ) + ) return _review_run(row) if row else None def fetch_latest_review_run( - conn: psycopg.Connection, + session: Session, claim_id: str, owner_user_id: str, ) -> ClaimReviewRun | None: - row = conn.execute( - REVIEW_RUN_SELECT - + """ - WHERE claim_id = %s - AND owner_user_id = %s - ORDER BY created_at DESC - LIMIT 1 - """, - (claim_id, owner_user_id), - ).fetchone() + row = session.scalar( + select(ClaimReviewRunRow) + .where( + ClaimReviewRunRow.claim_id == claim_id, + ClaimReviewRunRow.owner_user_id == owner_user_id, + ) + .order_by(ClaimReviewRunRow.created_at.desc()) + .limit(1) + ) return _review_run(row) if row else None diff --git a/src/backend/supportagent/db/__init__.py b/src/backend/supportagent/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/supportagent/db/base.py b/src/backend/supportagent/db/base.py new file mode 100644 index 0000000..8eee90f --- /dev/null +++ b/src/backend/supportagent/db/base.py @@ -0,0 +1,12 @@ +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + metadata = MetaData( + naming_convention={ + "pk": "%(table_name)s_pkey", + "uq": "%(table_name)s_%(column_0_N_name)s_key", + "fk": "%(table_name)s_%(column_0_N_name)s_fkey", + } + ) diff --git a/src/backend/supportagent/db/models/__init__.py b/src/backend/supportagent/db/models/__init__.py new file mode 100644 index 0000000..56727b2 --- /dev/null +++ b/src/backend/supportagent/db/models/__init__.py @@ -0,0 +1,43 @@ +from supportagent.db.models.auth import PasswordResetToken, User, UserSession +from supportagent.db.models.claims import ( + Claim, + ClaimActionApproval, + ClaimAuditEvent, + ClaimDocument, + ClaimReviewRun, + ProposedAction, +) +from supportagent.db.models.mcp import ( + McpIdempotencyRecord, + McpOAuthState, + McpServer, + McpToolAuditLog, + McpToolPolicy, + McpUserCredential, +) +from supportagent.db.models.memory import Conversation, ConversationMessage, LongMemory +from supportagent.db.models.rag import Chunk +from supportagent.db.models.uploads import UploadedFile + +__all__ = [ + "Chunk", + "Claim", + "ClaimActionApproval", + "ClaimAuditEvent", + "ClaimDocument", + "ClaimReviewRun", + "Conversation", + "ConversationMessage", + "LongMemory", + "McpIdempotencyRecord", + "McpOAuthState", + "McpServer", + "McpToolAuditLog", + "McpToolPolicy", + "McpUserCredential", + "PasswordResetToken", + "ProposedAction", + "UploadedFile", + "User", + "UserSession", +] diff --git a/src/backend/supportagent/db/models/auth.py b/src/backend/supportagent/db/models/auth.py new file mode 100644 index 0000000..5018607 --- /dev/null +++ b/src/backend/supportagent/db/models/auth.py @@ -0,0 +1,61 @@ +from datetime import datetime + +from sqlalchemy import ForeignKey, Index, Text, func, text +from sqlalchemy.dialects.postgresql import TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from supportagent.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(Text, primary_key=True) + email: Mapped[str] = mapped_column(Text, unique=True) + display_name: Mapped[str | None] = mapped_column(Text) + password_hash: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class UserSession(Base): + __tablename__ = "user_sessions" + __table_args__ = ( + Index("user_sessions_token_hash_idx", "token_hash"), + Index("user_sessions_user_expires_idx", "user_id", text("expires_at DESC")), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + user_id: Mapped[str] = mapped_column( + Text, ForeignKey("users.id", ondelete="CASCADE") + ) + token_hash: Mapped[str] = mapped_column(Text, unique=True) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True)) + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + __table_args__ = ( + Index("password_reset_tokens_token_hash_idx", "token_hash"), + Index( + "password_reset_tokens_user_expires_idx", "user_id", text("expires_at DESC") + ), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + user_id: Mapped[str] = mapped_column( + Text, ForeignKey("users.id", ondelete="CASCADE") + ) + token_hash: Mapped[str] = mapped_column(Text, unique=True) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True)) + used_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) diff --git a/src/backend/supportagent/db/models/claims.py b/src/backend/supportagent/db/models/claims.py new file mode 100644 index 0000000..bc1730b --- /dev/null +++ b/src/backend/supportagent/db/models/claims.py @@ -0,0 +1,146 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Index, Text, func, text +from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from supportagent.db.base import Base + + +class Claim(Base): + __tablename__ = "claims" + __table_args__ = ( + Index("claims_owner_updated_idx", "owner_user_id", text("updated_at DESC")), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + owner_user_id: Mapped[str] = mapped_column(Text) + policy_id: Mapped[str] = mapped_column(Text) + product_line: Mapped[str] = mapped_column(Text, server_default="unknown") + policy_version: Mapped[str | None] = mapped_column(Text) + jurisdiction: Mapped[str] = mapped_column(Text, server_default="DE") + customer_reference: Mapped[str] = mapped_column(Text) + claim_type: Mapped[str] = mapped_column(Text) + incident_date: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class ClaimDocument(Base): + __tablename__ = "claim_documents" + __table_args__ = (Index("claim_documents_claim_idx", "claim_id", "created_at"),) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + claim_id: Mapped[str] = mapped_column( + Text, ForeignKey("claims.id", ondelete="CASCADE") + ) + uploaded_file_id: Mapped[str | None] = mapped_column(Text) + document_type: Mapped[str] = mapped_column(Text) + filename: Mapped[str] = mapped_column(Text) + extraction_status: Mapped[str] = mapped_column(Text, server_default="PENDING") + extracted_fields: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class ClaimAuditEvent(Base): + __tablename__ = "claim_audit_events" + __table_args__ = (Index("claim_audit_claim_idx", "claim_id", "created_at"),) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + claim_id: Mapped[str] = mapped_column( + Text, ForeignKey("claims.id", ondelete="CASCADE") + ) + actor_user_id: Mapped[str] = mapped_column(Text) + event_type: Mapped[str] = mapped_column(Text) + payload: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class ClaimReviewRun(Base): + __tablename__ = "claim_review_runs" + __table_args__ = ( + Index("claim_review_runs_claim_idx", "claim_id", text("created_at DESC")), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + claim_id: Mapped[str] = mapped_column( + Text, ForeignKey("claims.id", ondelete="CASCADE") + ) + owner_user_id: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text) + current_step: Mapped[str] = mapped_column(Text) + result: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + error: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + completed_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + + +class ProposedAction(Base): + __tablename__ = "proposed_actions" + __table_args__ = ( + Index("proposed_actions_claim_idx", "claim_id", text("created_at DESC")), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + claim_id: Mapped[str] = mapped_column( + Text, ForeignKey("claims.id", ondelete="CASCADE") + ) + run_id: Mapped[str | None] = mapped_column(Text) + action_type: Mapped[str] = mapped_column(Text) + tool_server: Mapped[str] = mapped_column(Text) + tool_name: Mapped[str] = mapped_column(Text) + arguments: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + reason: Mapped[str] = mapped_column(Text) + risk_level: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text) + proposed_by: Mapped[str] = mapped_column(Text) + approved_by: Mapped[str | None] = mapped_column(Text) + approved_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class ClaimActionApproval(Base): + __tablename__ = "claim_action_approvals" + __table_args__ = ( + CheckConstraint( + "decision IN ('APPROVED', 'REJECTED')", + name="claim_action_approvals_decision_check", + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + proposed_action_id: Mapped[str] = mapped_column( + Text, ForeignKey("proposed_actions.id", ondelete="CASCADE") + ) + actor_user_id: Mapped[str] = mapped_column(Text) + decision: Mapped[str] = mapped_column(Text) + comment: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) diff --git a/src/backend/supportagent/db/models/mcp.py b/src/backend/supportagent/db/models/mcp.py new file mode 100644 index 0000000..7a9c520 --- /dev/null +++ b/src/backend/supportagent/db/models/mcp.py @@ -0,0 +1,143 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + ForeignKey, + Index, + Text, + func, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from supportagent.db.base import Base + + +class McpServer(Base): + __tablename__ = "mcp_servers" + __table_args__ = ( + CheckConstraint( + "transport IN ('stdio', 'sse', 'streamable_http')", + name="mcp_servers_transport_check", + ), + ) + + server_name: Mapped[str] = mapped_column(Text, primary_key=True) + display_name: Mapped[str] = mapped_column(Text) + transport: Mapped[str] = mapped_column(Text) + config: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + enabled: Mapped[bool] = mapped_column(Boolean, server_default=text("true")) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class McpToolPolicy(Base): + __tablename__ = "mcp_tool_policies" + + server_name: Mapped[str] = mapped_column( + Text, + ForeignKey("mcp_servers.server_name", ondelete="CASCADE"), + primary_key=True, + ) + tool_name: Mapped[str] = mapped_column(Text, primary_key=True) + enabled: Mapped[bool] = mapped_column(Boolean, server_default=text("true")) + auto_allowed: Mapped[bool] = mapped_column(Boolean, server_default=text("false")) + requires_confirmation: Mapped[bool] = mapped_column( + Boolean, server_default=text("true") + ) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class McpUserCredential(Base): + __tablename__ = "mcp_user_credentials" + + user_id: Mapped[str] = mapped_column(Text, primary_key=True) + server_name: Mapped[str] = mapped_column( + Text, + ForeignKey("mcp_servers.server_name", ondelete="CASCADE"), + primary_key=True, + ) + credentials: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class McpOAuthState(Base): + __tablename__ = "mcp_oauth_states" + + state: Mapped[str] = mapped_column(Text, primary_key=True) + user_id: Mapped[str] = mapped_column(Text) + provider: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + expires_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True)) + + +class McpToolAuditLog(Base): + __tablename__ = "mcp_tool_audit_logs" + __table_args__ = ( + CheckConstraint( + "status IN ('success', 'error', 'blocked')", + name="mcp_tool_audit_logs_status_check", + ), + Index("mcp_tool_audit_user_created_idx", "user_id", text("created_at DESC")), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[str] = mapped_column(Text) + server_name: Mapped[str] = mapped_column(Text) + tool_name: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(Text) + arguments: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + result_preview: Mapped[str | None] = mapped_column(Text) + error: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class McpIdempotencyRecord(Base): + __tablename__ = "mcp_idempotency_records" + __table_args__ = ( + CheckConstraint( + "status IN ('in_progress', 'succeeded')", + name="mcp_idempotency_records_status_check", + ), + ) + + user_id: Mapped[str] = mapped_column(Text, primary_key=True) + server_name: Mapped[str] = mapped_column(Text, primary_key=True) + tool_name: Mapped[str] = mapped_column(Text, primary_key=True) + idempotency_key: Mapped[str] = mapped_column(Text, primary_key=True) + status: Mapped[str] = mapped_column(Text) + result: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) diff --git a/src/backend/supportagent/db/models/memory.py b/src/backend/supportagent/db/models/memory.py new file mode 100644 index 0000000..885f552 --- /dev/null +++ b/src/backend/supportagent/db/models/memory.py @@ -0,0 +1,68 @@ +from datetime import datetime +from typing import Any + +from pgvector.sqlalchemy import Vector +from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Index, Text, func, text +from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from supportagent.db.base import Base + + +class Conversation(Base): + __tablename__ = "conversations" + + thread_id: Mapped[str] = mapped_column(Text, primary_key=True) + user_id: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class ConversationMessage(Base): + __tablename__ = "conversation_messages" + __table_args__ = ( + CheckConstraint( + "role IN ('user', 'assistant')", name="conversation_messages_role_check" + ), + Index( + "conversation_messages_thread_created_idx", + "thread_id", + text("created_at DESC"), + ), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + thread_id: Mapped[str] = mapped_column( + Text, ForeignKey("conversations.thread_id", ondelete="CASCADE") + ) + role: Mapped[str] = mapped_column(Text) + content: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) + + +class LongMemory(Base): + __tablename__ = "long_memories" + __table_args__ = ( + Index("long_memories_user_created_idx", "user_id", text("created_at DESC")), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[str] = mapped_column(Text) + thread_id: Mapped[str | None] = mapped_column( + Text, ForeignKey("conversations.thread_id", ondelete="SET NULL") + ) + kind: Mapped[str] = mapped_column(Text, server_default="conversation_summary") + content: Mapped[str] = mapped_column(Text) + embedding: Mapped[list[float] | None] = mapped_column(Vector(1024)) + metadata_: Mapped[dict[str, Any]] = mapped_column( + "metadata", JSONB, server_default=text("'{}'::jsonb") + ) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) diff --git a/src/backend/supportagent/db/models/rag.py b/src/backend/supportagent/db/models/rag.py new file mode 100644 index 0000000..4d999ee --- /dev/null +++ b/src/backend/supportagent/db/models/rag.py @@ -0,0 +1,28 @@ +from typing import Any + +from pgvector.sqlalchemy import Vector +from sqlalchemy import Index, Integer, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from supportagent.db.base import Base + + +class Chunk(Base): + __tablename__ = "chunks" + __table_args__ = ( + UniqueConstraint("document_id", "chunk_index"), + Index( + "chunks_embedding_hnsw_idx", + "embedding", + postgresql_using="hnsw", + postgresql_ops={"embedding": "vector_cosine_ops"}, + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + document_id: Mapped[str] = mapped_column(Text) + chunk_index: Mapped[int] = mapped_column(Integer) + content: Mapped[str] = mapped_column(Text) + embedding: Mapped[list[float] | None] = mapped_column(Vector(1024)) + metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB) diff --git a/src/backend/supportagent/db/models/uploads.py b/src/backend/supportagent/db/models/uploads.py new file mode 100644 index 0000000..f87b99b --- /dev/null +++ b/src/backend/supportagent/db/models/uploads.py @@ -0,0 +1,32 @@ +from datetime import datetime +from typing import Any + +from sqlalchemy import Index, Text, func, text +from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from supportagent.db.base import Base + + +class UploadedFile(Base): + __tablename__ = "uploaded_files" + __table_args__ = ( + Index("uploaded_files_user_created_idx", "user_id", text("created_at DESC")), + ) + + id: Mapped[str] = mapped_column(Text, primary_key=True) + user_id: Mapped[str] = mapped_column(Text) + thread_id: Mapped[str | None] = mapped_column(Text) + filename: Mapped[str] = mapped_column(Text) + content_type: Mapped[str] = mapped_column(Text) + storage_provider: Mapped[str] = mapped_column(Text, server_default="local") + storage_bucket: Mapped[str] = mapped_column(Text, server_default="local") + storage_key: Mapped[str | None] = mapped_column(Text) + storage_path: Mapped[str | None] = mapped_column(Text) + image_analysis: Mapped[dict[str, Any]] = mapped_column( + JSONB, server_default=text("'{}'::jsonb") + ) + image_summary: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column( + TIMESTAMP(timezone=True), server_default=func.now() + ) diff --git a/src/backend/supportagent/db/session.py b/src/backend/supportagent/db/session.py new file mode 100644 index 0000000..f844757 --- /dev/null +++ b/src/backend/supportagent/db/session.py @@ -0,0 +1,26 @@ +import os + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +load_dotenv() + + +def get_database_url() -> str: + url = os.environ["DATABASE_URL"] + url = url.replace("postgres://", "postgresql://", 1) + return url.replace("postgres://", "postgresql+psycopg://", 1) + + +engine = create_engine( + get_database_url(), + pool_size=5, + max_overflow=10, + pool_pre_ping=True, + connect_args={ + "prepare_threshold": None, + "connect_timeout": int(os.environ.get("DATABASE_CONNECT_TIMEOUT_SECONDS", "5")), + }, +) +SessionLocal = sessionmaker(engine, expire_on_commit=False) diff --git a/src/backend/supportagent/mcp_client/store.py b/src/backend/supportagent/mcp_client/store.py index 8913402..052c57b 100644 --- a/src/backend/supportagent/mcp_client/store.py +++ b/src/backend/supportagent/mcp_client/store.py @@ -1,12 +1,20 @@ -import json -from datetime import datetime, timedelta, timezone from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from typing import Any -import psycopg import requests +from sqlalchemy import and_, delete, func, select, update +from sqlalchemy.dialects.postgresql import insert -from supportagent.rag.vector_store import get_connection +from supportagent.db.models import ( + McpIdempotencyRecord, + McpOAuthState, + McpServer, + McpToolAuditLog, + McpUserCredential, +) +from supportagent.db.models import McpToolPolicy as ToolPolicyRow +from supportagent.db.session import SessionLocal @dataclass(frozen=True) @@ -37,45 +45,6 @@ class MCPIdempotencyClaim: result: str | None = None -DEFAULT_SERVER_ROWS = [ - ( - "time_mcp", - "Local timezone-aware time MCP server", - "stdio", - json.dumps( - { - "command": "python", - "args": ["-m", "supportagent.mcp_servers.time_mcp", "--transport", "stdio"], - } - ), - True, - ), - ( - "weather_mcp", - "Local Google Weather MCP server", - "stdio", - json.dumps( - { - "command": "python", - "args": ["-m", "supportagent.mcp_servers.weather_mcp", "--transport", "stdio"], - } - ), - True, - ), - ( - "teams_mcp", - "Local Microsoft Graph MCP server", - "stdio", - json.dumps( - { - "command": "python", - "args": ["-m", "supportagent.mcp_servers.teams_mcp", "--transport", "stdio"], - } - ), - True, - ), -] - READ_ONLY_TOOL_NAMES = { "get_my_profile", "batch_get_user_info", @@ -89,185 +58,21 @@ class MCPIdempotencyClaim: "get_current_time", } -PERSONAL_ACCOUNT_DISABLED_TOOL_NAMES = { - "batch_get_user_info", - "list_chats", - "create_message", -} - -DEFAULT_TOOL_POLICIES = [ - ("time_mcp", "get_current_time"), - ("weather_mcp", "get_weather"), - ("teams_mcp", "get_my_profile"), - ("teams_mcp", "batch_get_user_info"), - ("teams_mcp", "create_calendar"), - ("teams_mcp", "delete_calendar"), - ("teams_mcp", "get_calendar_info"), - ("teams_mcp", "get_calendars_list"), - ("teams_mcp", "update_calendar"), - ("teams_mcp", "create_calendar_event"), - ("teams_mcp", "create_default_calendar_event"), - ("teams_mcp", "append_calendar_event_attendee"), - ("teams_mcp", "get_calendar_event"), - ("teams_mcp", "update_calendar_event"), - ("teams_mcp", "delete_calendar_event"), - ("teams_mcp", "create_document"), - ("teams_mcp", "get_document"), - ("teams_mcp", "create_folder"), - ("teams_mcp", "list_folder_files"), - ("teams_mcp", "list_chats"), - ("teams_mcp", "create_message"), -] - - -def create_mcp_schema(conn: psycopg.Connection) -> None: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS mcp_servers ( - server_name TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - transport TEXT NOT NULL CHECK (transport IN ('stdio', 'sse', 'streamable_http')), - config JSONB NOT NULL DEFAULT '{}'::jsonb, - enabled BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS mcp_tool_policies ( - server_name TEXT NOT NULL REFERENCES mcp_servers(server_name) ON DELETE CASCADE, - tool_name TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT true, - auto_allowed BOOLEAN NOT NULL DEFAULT false, - requires_confirmation BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (server_name, tool_name) - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS mcp_user_credentials ( - user_id TEXT NOT NULL, - server_name TEXT NOT NULL REFERENCES mcp_servers(server_name) ON DELETE CASCADE, - credentials JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, server_name) - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS mcp_oauth_states ( - state TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - provider TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - expires_at TIMESTAMPTZ NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS mcp_tool_audit_logs ( - id BIGSERIAL PRIMARY KEY, - user_id TEXT NOT NULL, - server_name TEXT NOT NULL, - tool_name TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('success', 'error', 'blocked')), - arguments JSONB NOT NULL DEFAULT '{}'::jsonb, - result_preview TEXT, - error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS mcp_idempotency_records ( - user_id TEXT NOT NULL, - server_name TEXT NOT NULL, - tool_name TEXT NOT NULL, - idempotency_key TEXT NOT NULL, - status TEXT NOT NULL CHECK (status IN ('in_progress', 'succeeded')), - result TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (user_id, server_name, tool_name, idempotency_key) - ) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS mcp_tool_audit_user_created_idx - ON mcp_tool_audit_logs (user_id, created_at DESC) - """ - ) - for server_name, display_name, transport, config, enabled in DEFAULT_SERVER_ROWS: - conn.execute( - """ - INSERT INTO mcp_servers (server_name, display_name, transport, config, enabled) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (server_name) DO NOTHING - """, - (server_name, display_name, transport, config, enabled), - ) - for server_name, tool_name in DEFAULT_TOOL_POLICIES: - is_read_only = tool_name in READ_ONLY_TOOL_NAMES - is_enabled = tool_name not in PERSONAL_ACCOUNT_DISABLED_TOOL_NAMES - conn.execute( - """ - INSERT INTO mcp_tool_policies - (server_name, tool_name, enabled, auto_allowed, requires_confirmation) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (server_name, tool_name) - DO UPDATE SET - enabled = EXCLUDED.enabled, - auto_allowed = EXCLUDED.auto_allowed, - requires_confirmation = EXCLUDED.requires_confirmation, - updated_at = now() - """, - (server_name, tool_name, is_enabled, is_enabled and is_read_only, is_enabled and not is_read_only), - ) - conn.commit() - - -def ensure_mcp_schema() -> None: - conn = get_connection() - try: - create_mcp_schema(conn) - finally: - conn.close() - def list_mcp_servers() -> list[dict[str, Any]]: - ensure_mcp_schema() - conn = get_connection() - try: - rows = conn.execute( - """ - SELECT server_name, display_name, transport, config, enabled, updated_at - FROM mcp_servers - ORDER BY server_name - """ - ).fetchall() + with SessionLocal() as session: + servers = session.scalars(select(McpServer).order_by(McpServer.server_name)) return [ { - "server_name": row[0], - "display_name": row[1], - "transport": row[2], - "config": row[3], - "enabled": row[4], - "updated_at": row[5].isoformat(), + "server_name": server.server_name, + "display_name": server.display_name, + "transport": server.transport, + "config": server.config, + "enabled": server.enabled, + "updated_at": server.updated_at.isoformat(), } - for row in rows + for server in servers ] - finally: - conn.close() def upsert_mcp_server( @@ -278,68 +83,60 @@ def upsert_mcp_server( config: dict[str, Any], enabled: bool, ) -> None: - ensure_mcp_schema() - conn = get_connection() - try: - conn.execute( - """ - INSERT INTO mcp_servers (server_name, display_name, transport, config, enabled) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (server_name) - DO UPDATE SET - display_name = EXCLUDED.display_name, - transport = EXCLUDED.transport, - config = EXCLUDED.config, - enabled = EXCLUDED.enabled, - updated_at = now() - """, - (server_name, display_name, transport, json.dumps(config), enabled), - ) - conn.commit() - finally: - conn.close() + stmt = insert(McpServer).values( + server_name=server_name, + display_name=display_name, + transport=transport, + config=config, + enabled=enabled, + ) + stmt = stmt.on_conflict_do_update( + index_elements=[McpServer.server_name], + set_={ + "display_name": stmt.excluded.display_name, + "transport": stmt.excluded.transport, + "config": stmt.excluded.config, + "enabled": stmt.excluded.enabled, + "updated_at": func.now(), + }, + ) + with SessionLocal.begin() as session: + session.execute(stmt) def list_tool_policies() -> list[MCPToolPolicy]: - ensure_mcp_schema() - conn = get_connection() - try: - rows = conn.execute( - """ - SELECT server_name, tool_name, enabled, auto_allowed, requires_confirmation - FROM mcp_tool_policies - ORDER BY server_name, tool_name - """ - ).fetchall() + with SessionLocal() as session: + rows = session.execute( + select( + ToolPolicyRow.server_name, + ToolPolicyRow.tool_name, + ToolPolicyRow.enabled, + ToolPolicyRow.auto_allowed, + ToolPolicyRow.requires_confirmation, + ).order_by(ToolPolicyRow.server_name, ToolPolicyRow.tool_name) + ) return [MCPToolPolicy(*row) for row in rows] - finally: - conn.close() def get_tool_policy(server_name: str, tool_name: str) -> MCPToolPolicy: - ensure_mcp_schema() - conn = get_connection() - try: - row = conn.execute( - """ - SELECT server_name, tool_name, enabled, auto_allowed, requires_confirmation - FROM mcp_tool_policies - WHERE server_name = %s AND tool_name = %s - """, - (server_name, tool_name), - ).fetchone() - if row: - return MCPToolPolicy(*row) - is_read_only = tool_name in READ_ONLY_TOOL_NAMES + with SessionLocal() as session: + row = session.get(ToolPolicyRow, (server_name, tool_name)) + if row: return MCPToolPolicy( - server_name=server_name, - tool_name=tool_name, - enabled=True, - auto_allowed=is_read_only, - requires_confirmation=not is_read_only, + server_name=row.server_name, + tool_name=row.tool_name, + enabled=row.enabled, + auto_allowed=row.auto_allowed, + requires_confirmation=row.requires_confirmation, ) - finally: - conn.close() + is_read_only = tool_name in READ_ONLY_TOOL_NAMES + return MCPToolPolicy( + server_name=server_name, + tool_name=tool_name, + enabled=True, + auto_allowed=is_read_only, + requires_confirmation=not is_read_only, + ) def tool_auto_allowed(server_name: str, tool_name: str) -> bool: @@ -356,6 +153,17 @@ def tool_manual_allowed(server_name: str, tool_name: str, confirmed: bool) -> tu return True, None +def _idempotency_key_matches( + user_id: str, server_name: str, tool_name: str, idempotency_key: str +): + return and_( + McpIdempotencyRecord.user_id == user_id, + McpIdempotencyRecord.server_name == server_name, + McpIdempotencyRecord.tool_name == tool_name, + McpIdempotencyRecord.idempotency_key == idempotency_key, + ) + + def claim_idempotency_key( *, user_id: str, @@ -364,38 +172,30 @@ def claim_idempotency_key( idempotency_key: str, ) -> MCPIdempotencyClaim: """Atomically reserve a write action or replay its earlier successful result.""" - ensure_mcp_schema() - conn = get_connection() - try: - inserted = conn.execute( - """ - INSERT INTO mcp_idempotency_records - (user_id, server_name, tool_name, idempotency_key, status) - VALUES (%s, %s, %s, %s, 'in_progress') - ON CONFLICT DO NOTHING - RETURNING status - """, - (user_id, server_name, tool_name, idempotency_key), - ).fetchone() + with SessionLocal.begin() as session: + inserted = session.scalar( + insert(McpIdempotencyRecord) + .values( + user_id=user_id, + server_name=server_name, + tool_name=tool_name, + idempotency_key=idempotency_key, + status="in_progress", + ) + .on_conflict_do_nothing() + .returning(McpIdempotencyRecord.status) + ) if inserted: - conn.commit() return MCPIdempotencyClaim(status="execute") - row = conn.execute( - """ - SELECT status, result - FROM mcp_idempotency_records - WHERE user_id = %s AND server_name = %s AND tool_name = %s - AND idempotency_key = %s - """, - (user_id, server_name, tool_name, idempotency_key), - ).fetchone() - conn.commit() - if row and row[0] == "succeeded": - return MCPIdempotencyClaim(status="replay", result=row[1]) - return MCPIdempotencyClaim(status="in_progress") - finally: - conn.close() + row = session.execute( + select(McpIdempotencyRecord.status, McpIdempotencyRecord.result).where( + _idempotency_key_matches(user_id, server_name, tool_name, idempotency_key) + ) + ).first() + if row and row.status == "succeeded": + return MCPIdempotencyClaim(status="replay", result=row.result) + return MCPIdempotencyClaim(status="in_progress") def complete_idempotency_key( @@ -406,21 +206,12 @@ def complete_idempotency_key( idempotency_key: str, result: str, ) -> None: - ensure_mcp_schema() - conn = get_connection() - try: - conn.execute( - """ - UPDATE mcp_idempotency_records - SET status = 'succeeded', result = %s, updated_at = now() - WHERE user_id = %s AND server_name = %s AND tool_name = %s - AND idempotency_key = %s - """, - (result, user_id, server_name, tool_name, idempotency_key), + with SessionLocal.begin() as session: + session.execute( + update(McpIdempotencyRecord) + .where(_idempotency_key_matches(user_id, server_name, tool_name, idempotency_key)) + .values(status="succeeded", result=result, updated_at=func.now()) ) - conn.commit() - finally: - conn.close() def abandon_idempotency_key( @@ -430,94 +221,68 @@ def abandon_idempotency_key( tool_name: str, idempotency_key: str, ) -> None: - ensure_mcp_schema() - conn = get_connection() - try: - conn.execute( - """ - DELETE FROM mcp_idempotency_records - WHERE user_id = %s AND server_name = %s AND tool_name = %s - AND idempotency_key = %s AND status = 'in_progress' - """, - (user_id, server_name, tool_name, idempotency_key), + with SessionLocal.begin() as session: + session.execute( + delete(McpIdempotencyRecord).where( + _idempotency_key_matches(user_id, server_name, tool_name, idempotency_key), + McpIdempotencyRecord.status == "in_progress", + ) ) - conn.commit() - finally: - conn.close() def upsert_user_credentials(user_id: str, server_name: str, credentials: dict[str, Any]) -> None: - ensure_mcp_schema() - conn = get_connection() - try: - conn.execute( - """ - INSERT INTO mcp_user_credentials (user_id, server_name, credentials) - VALUES (%s, %s, %s) - ON CONFLICT (user_id, server_name) - DO UPDATE SET credentials = EXCLUDED.credentials, updated_at = now() - """, - (user_id, server_name, json.dumps(credentials)), - ) - conn.commit() - finally: - conn.close() + stmt = insert(McpUserCredential).values( + user_id=user_id, server_name=server_name, credentials=credentials + ) + stmt = stmt.on_conflict_do_update( + index_elements=[McpUserCredential.user_id, McpUserCredential.server_name], + set_={"credentials": stmt.excluded.credentials, "updated_at": func.now()}, + ) + with SessionLocal.begin() as session: + session.execute(stmt) def save_oauth_state(state: str, user_id: str, provider: str, ttl_minutes: int = 10) -> None: - ensure_mcp_schema() expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes) - conn = get_connection() - try: - conn.execute( - """ - INSERT INTO mcp_oauth_states (state, user_id, provider, expires_at) - VALUES (%s, %s, %s, %s) - ON CONFLICT (state) - DO UPDATE SET user_id = EXCLUDED.user_id, provider = EXCLUDED.provider, expires_at = EXCLUDED.expires_at - """, - (state, user_id, provider, expires_at), - ) - conn.commit() - finally: - conn.close() + stmt = insert(McpOAuthState).values( + state=state, user_id=user_id, provider=provider, expires_at=expires_at + ) + stmt = stmt.on_conflict_do_update( + index_elements=[McpOAuthState.state], + set_={ + "user_id": stmt.excluded.user_id, + "provider": stmt.excluded.provider, + "expires_at": stmt.excluded.expires_at, + }, + ) + with SessionLocal.begin() as session: + session.execute(stmt) def consume_oauth_state(state: str, provider: str) -> str | None: - ensure_mcp_schema() - conn = get_connection() - try: - row = conn.execute( - """ - DELETE FROM mcp_oauth_states - WHERE state = %s AND provider = %s AND expires_at > now() - RETURNING user_id - """, - (state, provider), - ).fetchone() - conn.commit() - return row[0] if row else None - finally: - conn.close() + with SessionLocal.begin() as session: + return session.scalar( + delete(McpOAuthState) + .where( + McpOAuthState.state == state, + McpOAuthState.provider == provider, + McpOAuthState.expires_at > func.now(), + ) + .returning(McpOAuthState.user_id) + ) def get_user_credentials(user_id: str | None, server_name: str) -> dict[str, Any]: if not user_id: return {} - ensure_mcp_schema() - conn = get_connection() - try: - row = conn.execute( - """ - SELECT credentials - FROM mcp_user_credentials - WHERE user_id = %s AND server_name = %s - """, - (user_id, server_name), - ).fetchone() - return row[0] if row else {} - finally: - conn.close() + with SessionLocal() as session: + credentials = session.scalar( + select(McpUserCredential.credentials).where( + McpUserCredential.user_id == user_id, + McpUserCredential.server_name == server_name, + ) + ) + return credentials or {} def inject_user_credentials( @@ -615,58 +380,39 @@ def add_audit_log( result_preview: str | None = None, error: str | None = None, ) -> None: - ensure_mcp_schema() - conn = get_connection() - try: - conn.execute( - """ - INSERT INTO mcp_tool_audit_logs - (user_id, server_name, tool_name, status, arguments, result_preview, error) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, - ( - user_id, - server_name, - tool_name, - status, - json.dumps(redact_arguments(arguments), ensure_ascii=False), - result_preview, - error, - ), + with SessionLocal.begin() as session: + session.add( + McpToolAuditLog( + user_id=user_id, + server_name=server_name, + tool_name=tool_name, + status=status, + arguments=redact_arguments(arguments), + result_preview=result_preview, + error=error, + ) ) - conn.commit() - finally: - conn.close() def list_audit_logs(user_id: str, limit: int = 50) -> list[MCPAuditRecord]: - ensure_mcp_schema() - conn = get_connection() - try: - rows = conn.execute( - """ - SELECT id, user_id, server_name, tool_name, status, arguments, - result_preview, error, created_at - FROM mcp_tool_audit_logs - WHERE user_id = %s - ORDER BY created_at DESC, id DESC - LIMIT %s - """, - (user_id, limit), - ).fetchall() + with SessionLocal() as session: + logs = session.scalars( + select(McpToolAuditLog) + .where(McpToolAuditLog.user_id == user_id) + .order_by(McpToolAuditLog.created_at.desc(), McpToolAuditLog.id.desc()) + .limit(limit) + ) return [ MCPAuditRecord( - id=row[0], - user_id=row[1], - server_name=row[2], - tool_name=row[3], - status=row[4], - arguments=row[5], - result_preview=row[6], - error=row[7], - created_at=row[8].isoformat(), + id=log.id, + user_id=log.user_id, + server_name=log.server_name, + tool_name=log.tool_name, + status=log.status, + arguments=log.arguments, + result_preview=log.result_preview, + error=log.error, + created_at=log.created_at.isoformat(), ) - for row in rows + for log in logs ] - finally: - conn.close() diff --git a/src/backend/supportagent/memory/__init__.py b/src/backend/supportagent/memory/__init__.py index ab94832..06cfca1 100644 --- a/src/backend/supportagent/memory/__init__.py +++ b/src/backend/supportagent/memory/__init__.py @@ -5,13 +5,11 @@ load_memory_context, save_memory_turn, ) -from supportagent.memory.store import create_memory_schema __all__ = [ "ChatMessage", "ConversationThread", "LongMemory", - "create_memory_schema", "get_thread_messages", "list_conversation_threads", "load_memory_context", diff --git a/src/backend/supportagent/memory/service.py b/src/backend/supportagent/memory/service.py index 64825d5..567dc59 100644 --- a/src/backend/supportagent/memory/service.py +++ b/src/backend/supportagent/memory/service.py @@ -1,10 +1,10 @@ import logging +from supportagent.db.session import SessionLocal from supportagent.memory.schemas import ChatMessage, ConversationThread, LongMemory from supportagent.memory.store import ( add_long_memory, add_message, - create_memory_schema, ensure_conversation, get_short_memory, get_thread_messages as select_thread_messages, @@ -14,7 +14,6 @@ update_user_message as update_user_message_row, ) from supportagent.rag.embeddings import embed_texts, get_embedding_client -from supportagent.rag.vector_store import get_connection logger = logging.getLogger(__name__) @@ -30,21 +29,18 @@ def load_memory_context( return [], [] user_id = user_id or DEFAULT_USER_ID - conn = get_connection() try: - create_memory_schema(conn) - ensure_conversation(conn, thread_id, user_id) - short_memory = get_short_memory(conn, thread_id) - query_embedding = embed_texts(get_embedding_client(), [question])[0] - long_memories = search_long_memories(conn, user_id, query_embedding) - conn.commit() + with SessionLocal.begin() as session: + ensure_conversation(session, thread_id, user_id) + short_memory = get_short_memory(session, thread_id, user_id) + query_embedding = embed_texts(get_embedding_client(), [question])[0] + long_memories = search_long_memories(session, user_id, query_embedding) return short_memory, long_memories except Exception: - logger.exception("memory_load_failed thread_id=%s user_id=%s", thread_id, user_id) - conn.rollback() + logger.exception( + "memory_load_failed thread_id=%s user_id=%s", thread_id, user_id + ) return [], [] - finally: - conn.close() def get_thread_messages( @@ -52,36 +48,26 @@ def get_thread_messages( user_id: str, limit: int = 100, ) -> list[ChatMessage]: - conn = get_connection() try: - create_memory_schema(conn) - messages = select_thread_messages(conn, thread_id, user_id, limit=limit) - conn.commit() - return messages + with SessionLocal() as session: + return select_thread_messages(session, thread_id, user_id, limit=limit) except Exception: - logger.exception("thread_messages_load_failed thread_id=%s user_id=%s", thread_id, user_id) - conn.rollback() + logger.exception( + "thread_messages_load_failed thread_id=%s user_id=%s", thread_id, user_id + ) return [] - finally: - conn.close() def list_conversation_threads( user_id: str, limit: int = 30, ) -> list[ConversationThread]: - conn = get_connection() try: - create_memory_schema(conn) - threads = select_conversation_threads(conn, user_id=user_id, limit=limit) - conn.commit() - return threads + with SessionLocal() as session: + return select_conversation_threads(session, user_id=user_id, limit=limit) except Exception: logger.exception("conversation_threads_load_failed user_id=%s", user_id) - conn.rollback() return [] - finally: - conn.close() def update_thread_user_message( @@ -90,18 +76,19 @@ def update_thread_user_message( message_id: int, content: str, ) -> bool: - conn = get_connection() try: - create_memory_schema(conn) - updated = update_user_message_row(conn, thread_id, user_id, message_id, content) - conn.commit() - return updated + with SessionLocal.begin() as session: + return update_user_message_row( + session, thread_id, user_id, message_id, content + ) except Exception: - logger.exception("thread_message_update_failed thread_id=%s user_id=%s message_id=%s", thread_id, user_id, message_id) - conn.rollback() + logger.exception( + "thread_message_update_failed thread_id=%s user_id=%s message_id=%s", + thread_id, + user_id, + message_id, + ) return False - finally: - conn.close() def delete_thread_user_turn( @@ -109,18 +96,17 @@ def delete_thread_user_turn( user_id: str, message_id: int, ) -> bool: - conn = get_connection() try: - create_memory_schema(conn) - deleted = delete_user_turn_row(conn, thread_id, user_id, message_id) - conn.commit() - return deleted + with SessionLocal.begin() as session: + return delete_user_turn_row(session, thread_id, user_id, message_id) except Exception: - logger.exception("thread_turn_delete_failed thread_id=%s user_id=%s message_id=%s", thread_id, user_id, message_id) - conn.rollback() + logger.exception( + "thread_turn_delete_failed thread_id=%s user_id=%s message_id=%s", + thread_id, + user_id, + message_id, + ) return False - finally: - conn.close() def save_memory_turn( @@ -133,25 +119,26 @@ def save_memory_turn( return user_id = user_id or DEFAULT_USER_ID - conn = get_connection() try: - create_memory_schema(conn) - ensure_conversation(conn, thread_id, user_id) - add_message(conn, thread_id, "user", question) - add_message(conn, thread_id, "assistant", answer) - - memory_text = ( - "Conversation memory for future personalization. " - f"User asked: {question}\nAssistant answered: {answer}" - ) - memory_embedding = embed_texts(get_embedding_client(), [memory_text])[0] - add_long_memory(conn, user_id, thread_id, memory_text, memory_embedding) - conn.commit() + with SessionLocal.begin() as session: + if not ensure_conversation(session, thread_id, user_id): + logger.warning( + "thread_owner_mismatch thread_id=%s user_id=%s", thread_id, user_id + ) + return + add_message(session, thread_id, "user", question) + add_message(session, thread_id, "assistant", answer) + + memory_text = ( + "Conversation memory for future personalization. " + f"User asked: {question}\nAssistant answered: {answer}" + ) + memory_embedding = embed_texts(get_embedding_client(), [memory_text])[0] + add_long_memory(session, user_id, thread_id, memory_text, memory_embedding) except Exception: - logger.exception("memory_save_failed thread_id=%s user_id=%s", thread_id, user_id) - conn.rollback() - finally: - conn.close() + logger.exception( + "memory_save_failed thread_id=%s user_id=%s", thread_id, user_id + ) def record_mcp_action_turn( @@ -169,15 +156,16 @@ def record_mcp_action_turn( return user_id = user_id or DEFAULT_USER_ID - conn = get_connection() try: - create_memory_schema(conn) - ensure_conversation(conn, thread_id, user_id) - add_message(conn, thread_id, "user", question) - add_message(conn, thread_id, "assistant", summary) - conn.commit() + with SessionLocal.begin() as session: + if not ensure_conversation(session, thread_id, user_id): + logger.warning( + "thread_owner_mismatch thread_id=%s user_id=%s", thread_id, user_id + ) + return + add_message(session, thread_id, "user", question) + add_message(session, thread_id, "assistant", summary) except Exception: - logger.exception("mcp_action_turn_save_failed thread_id=%s user_id=%s", thread_id, user_id) - conn.rollback() - finally: - conn.close() + logger.exception( + "mcp_action_turn_save_failed thread_id=%s user_id=%s", thread_id, user_id + ) diff --git a/src/backend/supportagent/memory/store.py b/src/backend/supportagent/memory/store.py index 6d1eda8..916c2f6 100644 --- a/src/backend/supportagent/memory/store.py +++ b/src/backend/supportagent/memory/store.py @@ -1,313 +1,225 @@ -import json -import os - -import psycopg +from sqlalchemy import delete, func, select, update +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.orm import Session +from supportagent.db.models import Conversation, ConversationMessage +from supportagent.db.models import LongMemory as LongMemoryRow from supportagent.memory.schemas import ChatMessage, ConversationThread, LongMemory SHORT_MEMORY_LIMIT = 6 LONG_MEMORY_LIMIT = 3 -def create_memory_schema(conn: psycopg.Connection, dimensions: int | None = None) -> None: - dimensions = dimensions or int(os.environ.get("EMBEDDING_DIMENSIONS", "1024")) - conn.execute("CREATE EXTENSION IF NOT EXISTS vector") - conn.execute( - """ - CREATE TABLE IF NOT EXISTS conversations ( - thread_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS conversation_messages ( - id BIGSERIAL PRIMARY KEY, - thread_id TEXT NOT NULL REFERENCES conversations(thread_id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), - content TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute( - f""" - CREATE TABLE IF NOT EXISTS long_memories ( - id BIGSERIAL PRIMARY KEY, - user_id TEXT NOT NULL, - thread_id TEXT REFERENCES conversations(thread_id) ON DELETE SET NULL, - kind TEXT NOT NULL DEFAULT 'conversation_summary', - content TEXT NOT NULL, - embedding vector({dimensions}), - metadata JSONB NOT NULL DEFAULT '{{}}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ +def _to_chat_message(message: ConversationMessage) -> ChatMessage: + return ChatMessage( + id=message.id, + role=message.role, + content=message.content, + created_at=message.created_at, ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS conversation_messages_thread_created_idx - ON conversation_messages (thread_id, created_at DESC) - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS long_memories_user_created_idx - ON long_memories (user_id, created_at DESC) - """ - ) - conn.commit() -def ensure_conversation(conn: psycopg.Connection, thread_id: str, user_id: str) -> None: - conn.execute( - """ - INSERT INTO conversations (thread_id, user_id) - VALUES (%s, %s) - ON CONFLICT (thread_id) - DO UPDATE SET user_id = EXCLUDED.user_id, updated_at = now() - """, - (thread_id, user_id), - ) +def ensure_conversation(session: Session, thread_id: str, user_id: str) -> bool: + stmt = insert(Conversation).values(thread_id=thread_id, user_id=user_id) + stmt = stmt.on_conflict_do_update( + index_elements=[Conversation.thread_id], + set_={"updated_at": func.now()}, + where=Conversation.user_id == user_id, + ).returning(Conversation.thread_id) + return session.scalar(stmt) is not None def get_short_memory( - conn: psycopg.Connection, + session: Session, thread_id: str, + user_id: str, limit: int = SHORT_MEMORY_LIMIT, ) -> list[ChatMessage]: - rows = conn.execute( - """ - SELECT id, role, content, created_at - FROM conversation_messages - WHERE thread_id = %s - ORDER BY created_at DESC, id DESC - LIMIT %s - """, - (thread_id, limit), - ).fetchall() - return [ - ChatMessage(id=row[0], role=row[1], content=row[2], created_at=row[3]) - for row in reversed(rows) - ] + messages = session.scalars( + select(ConversationMessage) + .join(Conversation, Conversation.thread_id == ConversationMessage.thread_id) + .where( + ConversationMessage.thread_id == thread_id, + Conversation.user_id == user_id, + ) + .order_by(ConversationMessage.created_at.desc(), ConversationMessage.id.desc()) + .limit(limit) + ).all() + return [_to_chat_message(message) for message in reversed(messages)] def get_thread_messages( - conn: psycopg.Connection, + session: Session, thread_id: str, user_id: str, limit: int = 100, ) -> list[ChatMessage]: - rows = conn.execute( - """ - SELECT conversation_messages.id, - conversation_messages.role, - conversation_messages.content, - conversation_messages.created_at - FROM conversation_messages - JOIN conversations - ON conversations.thread_id = conversation_messages.thread_id - WHERE conversation_messages.thread_id = %s - AND conversations.user_id = %s - ORDER BY conversation_messages.created_at ASC, - conversation_messages.id ASC - LIMIT %s - """, - (thread_id, user_id, limit), - ).fetchall() - return [ - ChatMessage(id=row[0], role=row[1], content=row[2], created_at=row[3]) - for row in rows - ] + messages = session.scalars( + select(ConversationMessage) + .join(Conversation, Conversation.thread_id == ConversationMessage.thread_id) + .where( + ConversationMessage.thread_id == thread_id, + Conversation.user_id == user_id, + ) + .order_by(ConversationMessage.created_at, ConversationMessage.id) + .limit(limit) + ) + return [_to_chat_message(message) for message in messages] def list_conversation_threads( - conn: psycopg.Connection, + session: Session, user_id: str, limit: int = 30, ) -> list[ConversationThread]: - rows = conn.execute( - """ - SELECT conversations.thread_id, - COALESCE( - NULLIF( - left( - min(conversation_messages.content) - FILTER (WHERE conversation_messages.role = 'user'), - 80 - ), - '' - ), - 'New chat' - ) AS title, - conversations.updated_at, - count(conversation_messages.id) AS message_count - FROM conversations - LEFT JOIN conversation_messages - ON conversation_messages.thread_id = conversations.thread_id - WHERE conversations.user_id = %s - GROUP BY conversations.thread_id, conversations.updated_at - ORDER BY conversations.updated_at DESC - LIMIT %s - """, - (user_id, limit), - ).fetchall() + first_question = func.min(ConversationMessage.content).filter( + ConversationMessage.role == "user" + ) + title = func.coalesce(func.nullif(func.left(first_question, 80), ""), "New chat") + rows = session.execute( + select( + Conversation.thread_id, + title.label("title"), + Conversation.updated_at, + func.count(ConversationMessage.id).label("message_count"), + ) + .outerjoin( + ConversationMessage, + ConversationMessage.thread_id == Conversation.thread_id, + ) + .where(Conversation.user_id == user_id) + .group_by(Conversation.thread_id, Conversation.updated_at) + .order_by(Conversation.updated_at.desc()) + .limit(limit) + ) return [ ConversationThread( - thread_id=row[0], - title=row[1], - updated_at=row[2], - message_count=row[3], + thread_id=row.thread_id, + title=row.title, + updated_at=row.updated_at, + message_count=row.message_count, ) for row in rows ] -def add_message(conn: psycopg.Connection, thread_id: str, role: str, content: str) -> None: - conn.execute( - """ - INSERT INTO conversation_messages (thread_id, role, content) - VALUES (%s, %s, %s) - """, - (thread_id, role, content), - ) +def add_message(session: Session, thread_id: str, role: str, content: str) -> None: + session.add(ConversationMessage(thread_id=thread_id, role=role, content=content)) -def update_user_message( - conn: psycopg.Connection, +def _owned_question( + session: Session, thread_id: str, user_id: str, message_id: int, - content: str, -) -> bool: - result = conn.execute( - """ - UPDATE conversation_messages - SET content = %s - FROM conversations - WHERE conversation_messages.id = %s - AND conversation_messages.thread_id = %s - AND conversation_messages.role = 'user' - AND conversations.thread_id = conversation_messages.thread_id - AND conversations.user_id = %s - """, - (content, message_id, thread_id, user_id), +) -> ConversationMessage | None: + return session.scalar( + select(ConversationMessage) + .join(Conversation, Conversation.thread_id == ConversationMessage.thread_id) + .where( + ConversationMessage.id == message_id, + ConversationMessage.thread_id == thread_id, + ConversationMessage.role == "user", + Conversation.user_id == user_id, + ) + ) + + +def _delete_answer_after(session: Session, question: ConversationMessage) -> None: + answer_id = session.scalar( + select(ConversationMessage.id) + .where( + ConversationMessage.thread_id == question.thread_id, + ConversationMessage.role == "assistant", + ConversationMessage.id > question.id, + ) + .order_by(ConversationMessage.id) + .limit(1) + ) + if answer_id is not None: + session.execute( + delete(ConversationMessage).where(ConversationMessage.id == answer_id) + ) + + +def _touch_conversation(session: Session, thread_id: str) -> None: + session.execute( + update(Conversation) + .where(Conversation.thread_id == thread_id) + .values(updated_at=func.now()) ) - if result.rowcount: - delete_following_assistant_message(conn, thread_id, user_id, message_id) - conn.execute("UPDATE conversations SET updated_at = now() WHERE thread_id = %s", (thread_id,)) - return bool(result.rowcount) -def delete_following_assistant_message( - conn: psycopg.Connection, +def update_user_message( + session: Session, thread_id: str, user_id: str, message_id: int, -) -> None: - conn.execute( - """ - DELETE FROM conversation_messages - WHERE id = ( - SELECT assistant.id - FROM conversation_messages AS user_message - JOIN conversations - ON conversations.thread_id = user_message.thread_id - JOIN LATERAL ( - SELECT id - FROM conversation_messages - WHERE thread_id = user_message.thread_id - AND role = 'assistant' - AND id > user_message.id - ORDER BY id ASC - LIMIT 1 - ) AS assistant ON true - WHERE user_message.id = %s - AND user_message.thread_id = %s - AND user_message.role = 'user' - AND conversations.user_id = %s - ) - """, - (message_id, thread_id, user_id), - ) + content: str, +) -> bool: + question = _owned_question(session, thread_id, user_id, message_id) + if question is None: + return False + question.content = content + _delete_answer_after(session, question) + _touch_conversation(session, thread_id) + return True def delete_user_turn( - conn: psycopg.Connection, + session: Session, thread_id: str, user_id: str, message_id: int, ) -> bool: - delete_following_assistant_message(conn, thread_id, user_id, message_id) - result = conn.execute( - """ - DELETE FROM conversation_messages - USING conversations - WHERE conversation_messages.id = %s - AND conversation_messages.thread_id = %s - AND conversation_messages.role = 'user' - AND conversations.thread_id = conversation_messages.thread_id - AND conversations.user_id = %s - """, - (message_id, thread_id, user_id), - ) - if result.rowcount: - conn.execute("UPDATE conversations SET updated_at = now() WHERE thread_id = %s", (thread_id,)) - return bool(result.rowcount) - conn.execute( - """ - UPDATE conversations - SET updated_at = now() - WHERE thread_id = %s - """, - (thread_id,), - ) + question = _owned_question(session, thread_id, user_id, message_id) + if question is None: + return False + _delete_answer_after(session, question) + session.delete(question) + _touch_conversation(session, thread_id) + return True def search_long_memories( - conn: psycopg.Connection, + session: Session, user_id: str, query_embedding: list[float], limit: int = LONG_MEMORY_LIMIT, ) -> list[LongMemory]: - rows = conn.execute( - """ - SELECT id, content, kind, embedding <=> %s::vector AS distance - FROM long_memories - WHERE user_id = %s - ORDER BY distance - LIMIT %s - """, - (query_embedding, user_id, limit), - ).fetchall() + distance = LongMemoryRow.embedding.cosine_distance(query_embedding) + rows = session.execute( + select( + LongMemoryRow.id, + LongMemoryRow.content, + LongMemoryRow.kind, + distance.label("distance"), + ) + .where(LongMemoryRow.user_id == user_id) + .order_by(distance) + .limit(limit) + ) return [ - LongMemory(id=row[0], content=row[1], kind=row[2], distance=row[3]) + LongMemory(id=row.id, content=row.content, kind=row.kind, distance=row.distance) for row in rows ] def add_long_memory( - conn: psycopg.Connection, + session: Session, user_id: str, thread_id: str, content: str, embedding: list[float], kind: str = "conversation_summary", ) -> None: - conn.execute( - """ - INSERT INTO long_memories (user_id, thread_id, kind, content, embedding, metadata) - VALUES (%s, %s, %s, %s, %s, %s) - """, - ( - user_id, - thread_id, - kind, - content, - embedding, - json.dumps({"source": "supportagent", "created_by": "answer_with_agent"}), - ), + session.add( + LongMemoryRow( + user_id=user_id, + thread_id=thread_id, + kind=kind, + content=content, + embedding=embedding, + metadata_={"source": "supportagent", "created_by": "answer_with_agent"}, + ) ) diff --git a/src/backend/supportagent/rag/index.py b/src/backend/supportagent/rag/index.py index 0b204e3..0a4310a 100644 --- a/src/backend/supportagent/rag/index.py +++ b/src/backend/supportagent/rag/index.py @@ -1,10 +1,11 @@ -import os +from dotenv import load_dotenv + +from supportagent.db.session import SessionLocal from .chunking import chunk_document -from dotenv import load_dotenv from .embeddings import embed_texts, get_embedding_client from .ingest import collect_documents -from .vector_store import create_schema, get_connection, upsert_chunk +from .vector_store import upsert_chunk def main() -> None: @@ -17,12 +18,9 @@ def main() -> None: client = get_embedding_client() embeddings = embed_texts(client, [chunk["content"] for chunk in chunks]) - dimensions = int(os.environ.get("EMBEDDING_DIMENSIONS", "1024")) - conn = get_connection() - create_schema(conn, dimensions) - for chunk, embedding in zip(chunks, embeddings): - upsert_chunk(conn, chunk, embedding) - conn.commit() + with SessionLocal.begin() as session: + for chunk, embedding in zip(chunks, embeddings): + upsert_chunk(session, chunk, embedding) print(f"Stored {len(chunks)} chunks in pgvector") diff --git a/src/backend/supportagent/rag/retrieval.py b/src/backend/supportagent/rag/retrieval.py index 63f0bea..dfb0372 100644 --- a/src/backend/supportagent/rag/retrieval.py +++ b/src/backend/supportagent/rag/retrieval.py @@ -1,15 +1,18 @@ +from supportagent.db.session import SessionLocal + from .embeddings import embed_texts, get_embedding_client -from .vector_store import get_connection, search +from .vector_store import search DEFAULT_LIMIT = 5 -def retrieve(question: str, limit: int = DEFAULT_LIMIT, source_filter: str | None = None) -> list[dict]: +def retrieve( + question: str, limit: int = DEFAULT_LIMIT, source_filter: str | None = None +) -> list[dict]: client = get_embedding_client() query_embedding = embed_texts(client, [question])[0] - conn = get_connection() - try: - return search(conn, query_embedding, limit=limit, source_filter=source_filter) - finally: - conn.close() + with SessionLocal() as session: + return search( + session, query_embedding, limit=limit, source_filter=source_filter + ) diff --git a/src/backend/supportagent/rag/vector_store.py b/src/backend/supportagent/rag/vector_store.py index 0e1ca77..4d147e7 100644 --- a/src/backend/supportagent/rag/vector_store.py +++ b/src/backend/supportagent/rag/vector_store.py @@ -1,98 +1,56 @@ -import json -import os +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.orm import Session -import psycopg -from pgvector.psycopg import register_vector +from supportagent.db.models import Chunk -def get_connection() -> psycopg.Connection: - conn = psycopg.connect( - os.environ["DATABASE_URL"], - prepare_threshold=None, - connect_timeout=int(os.environ.get("DATABASE_CONNECT_TIMEOUT_SECONDS", "5")), +def upsert_chunk(session: Session, chunk: dict, embedding: list[float]) -> None: + stmt = insert(Chunk).values( + document_id=chunk["document_id"], + chunk_index=chunk["chunk_index"], + content=chunk["content"], + embedding=embedding, + metadata=chunk["metadata"], ) - conn.execute("CREATE EXTENSION IF NOT EXISTS vector") - conn.commit() - register_vector(conn) - return conn - - -def create_schema(conn: psycopg.Connection, dimensions: int) -> None: - conn.execute( - f""" - CREATE TABLE IF NOT EXISTS chunks ( - id SERIAL PRIMARY KEY, - document_id TEXT NOT NULL, - chunk_index INT NOT NULL, - content TEXT NOT NULL, - embedding vector({dimensions}), - metadata JSONB NOT NULL, - UNIQUE (document_id, chunk_index) - ) - """ - ) - conn.commit() - - -def ensure_rag_schema() -> None: - dimensions = int(os.environ.get("EMBEDDING_DIMENSIONS", "1024")) - conn = get_connection() - try: - create_schema(conn, dimensions) - finally: - conn.close() - - -def upsert_chunk(conn: psycopg.Connection, chunk: dict, embedding: list[float]) -> None: - conn.execute( - """ - INSERT INTO chunks (document_id, chunk_index, content, embedding, metadata) - VALUES (%(document_id)s, %(chunk_index)s, %(content)s, %(embedding)s, %(metadata)s) - ON CONFLICT (document_id, chunk_index) - DO UPDATE SET - content = EXCLUDED.content, - embedding = EXCLUDED.embedding, - metadata = EXCLUDED.metadata - """, - { - "document_id": chunk["document_id"], - "chunk_index": chunk["chunk_index"], - "content": chunk["content"], - "embedding": embedding, - "metadata": json.dumps(chunk["metadata"], ensure_ascii=False), + stmt = stmt.on_conflict_do_update( + index_elements=[Chunk.document_id, Chunk.chunk_index], + set_={ + "content": stmt.excluded.content, + "embedding": stmt.excluded.embedding, + "metadata": stmt.excluded["metadata"], }, ) + session.execute(stmt) def search( - conn: psycopg.Connection, + session: Session, query_embedding: list[float], limit: int = 5, source_filter: str | None = None, ) -> list[dict]: - where_clause = "WHERE metadata->>'source' = %s" if source_filter else "" - params: list = [query_embedding] + distance = Chunk.embedding.cosine_distance(query_embedding).label("distance") + stmt = ( + select( + Chunk.document_id, + Chunk.chunk_index, + Chunk.content, + Chunk.metadata_, + distance, + ) + .order_by(distance) + .limit(limit) + ) if source_filter: - params.append(source_filter) - params.append(limit) - - rows = conn.execute( - f""" - SELECT document_id, chunk_index, content, metadata, embedding <=> %s::vector AS distance - FROM chunks - {where_clause} - ORDER BY distance - LIMIT %s - """, - params, - ).fetchall() + stmt = stmt.where(Chunk.metadata_["source"].astext == source_filter) return [ { - "document_id": row[0], - "chunk_index": row[1], - "content": row[2], - "metadata": row[3], - "distance": row[4], + "document_id": row.document_id, + "chunk_index": row.chunk_index, + "content": row.content, + "metadata": row.metadata_, + "distance": row.distance, } - for row in rows + for row in session.execute(stmt) ] diff --git a/src/backend/supportagent/uploads/__init__.py b/src/backend/supportagent/uploads/__init__.py index c08faf7..c2c7f9e 100644 --- a/src/backend/supportagent/uploads/__init__.py +++ b/src/backend/supportagent/uploads/__init__.py @@ -1,4 +1,11 @@ -from supportagent.uploads.service import UploadedImage, get_image_contexts, save_uploaded_image -from supportagent.uploads.store import ensure_upload_schema +from supportagent.uploads.service import ( + UploadedImage, + get_image_contexts, + save_uploaded_image, +) -__all__ = ["UploadedImage", "ensure_upload_schema", "get_image_contexts", "save_uploaded_image"] +__all__ = [ + "UploadedImage", + "get_image_contexts", + "save_uploaded_image", +] diff --git a/src/backend/supportagent/uploads/service.py b/src/backend/supportagent/uploads/service.py index b9f324d..78c105e 100644 --- a/src/backend/supportagent/uploads/service.py +++ b/src/backend/supportagent/uploads/service.py @@ -5,9 +5,12 @@ from fastapi import UploadFile -from supportagent.rag.vector_store import get_connection +from supportagent.db.session import SessionLocal from supportagent.storage import get_storage -from supportagent.uploads.store import create_uploaded_file, ensure_upload_schema, fetch_uploaded_files +from supportagent.uploads.store import ( + create_uploaded_file, + fetch_uploaded_files, +) from supportagent.vision.service import analyze_image ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} @@ -70,11 +73,9 @@ async def save_uploaded_image( ) image_summary = image_analysis.summary(filename) - ensure_upload_schema() - conn = get_connection() - try: + with SessionLocal.begin() as session: create_uploaded_file( - conn, + session, file_id=image_id, user_id=user_id, thread_id=thread_id, @@ -86,9 +87,6 @@ async def save_uploaded_image( image_summary=image_summary, image_analysis=image_analysis.to_dict(), ) - conn.commit() - finally: - conn.close() return UploadedImage( id=image_id, @@ -103,11 +101,8 @@ def get_image_contexts(user_id: str, image_ids: list[str] | None) -> list[str]: if not image_ids: return [] - conn = get_connection() - try: - rows = fetch_uploaded_files(conn, user_id=user_id, image_ids=image_ids) - finally: - conn.close() + with SessionLocal() as session: + rows = fetch_uploaded_files(session, user_id=user_id, image_ids=image_ids) contexts = [] for row in rows: diff --git a/src/backend/supportagent/uploads/store.py b/src/backend/supportagent/uploads/store.py index 8d7ff54..af43be0 100644 --- a/src/backend/supportagent/uploads/store.py +++ b/src/backend/supportagent/uploads/store.py @@ -1,57 +1,11 @@ -import psycopg -from psycopg.types.json import Json +from sqlalchemy import select +from sqlalchemy.orm import Session - -def ensure_upload_schema() -> None: - from supportagent.rag.vector_store import get_connection - - conn = get_connection() - try: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS uploaded_files ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - thread_id TEXT, - filename TEXT NOT NULL, - content_type TEXT NOT NULL, - storage_provider TEXT NOT NULL DEFAULT 'local', - storage_bucket TEXT NOT NULL DEFAULT 'local', - storage_key TEXT, - storage_path TEXT, - image_analysis JSONB NOT NULL DEFAULT '{}'::jsonb, - image_summary TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """ - ) - conn.execute("ALTER TABLE uploaded_files ADD COLUMN IF NOT EXISTS storage_provider TEXT NOT NULL DEFAULT 'local'") - conn.execute("ALTER TABLE uploaded_files ADD COLUMN IF NOT EXISTS storage_bucket TEXT NOT NULL DEFAULT 'local'") - conn.execute("ALTER TABLE uploaded_files ADD COLUMN IF NOT EXISTS storage_key TEXT") - conn.execute("ALTER TABLE uploaded_files ADD COLUMN IF NOT EXISTS storage_path TEXT") - conn.execute("ALTER TABLE uploaded_files ADD COLUMN IF NOT EXISTS image_analysis JSONB NOT NULL DEFAULT '{}'::jsonb") - conn.execute("ALTER TABLE uploaded_files ALTER COLUMN storage_path DROP NOT NULL") - conn.execute( - """ - UPDATE uploaded_files - SET storage_key = storage_path - WHERE storage_key IS NULL - AND storage_path IS NOT NULL - """ - ) - conn.execute( - """ - CREATE INDEX IF NOT EXISTS uploaded_files_user_created_idx - ON uploaded_files (user_id, created_at DESC) - """ - ) - conn.commit() - finally: - conn.close() +from supportagent.db.models import UploadedFile def create_uploaded_file( - conn: psycopg.Connection, + session: Session, file_id: str, user_id: str, thread_id: str | None, @@ -63,80 +17,65 @@ def create_uploaded_file( image_summary: str, image_analysis: dict[str, object] | None = None, ) -> None: - conn.execute( - """ - INSERT INTO uploaded_files ( - id, user_id, thread_id, filename, content_type, - storage_provider, storage_bucket, storage_key, image_summary, image_analysis + session.add( + UploadedFile( + id=file_id, + user_id=user_id, + thread_id=thread_id, + filename=filename, + content_type=content_type, + storage_provider=storage_provider, + storage_bucket=storage_bucket, + storage_key=storage_key, + image_summary=image_summary, + image_analysis=image_analysis or {}, ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - ( - file_id, - user_id, - thread_id, - filename, - content_type, - storage_provider, - storage_bucket, - storage_key, - image_summary, - Json(image_analysis or {}), - ), ) def fetch_uploaded_files( - conn: psycopg.Connection, + session: Session, user_id: str, image_ids: list[str], ) -> list[dict[str, str]]: - rows = conn.execute( - """ - SELECT id, filename, content_type, image_summary, image_analysis - FROM uploaded_files - WHERE user_id = %s - AND id = ANY(%s) - ORDER BY created_at ASC - """, - (user_id, image_ids), - ).fetchall() + files = session.scalars( + select(UploadedFile) + .where(UploadedFile.user_id == user_id, UploadedFile.id.in_(image_ids)) + .order_by(UploadedFile.created_at) + ) return [ { - "id": row[0], - "filename": row[1], - "content_type": row[2], - "image_summary": row[3], - "image_analysis": row[4] or {}, + "id": file.id, + "filename": file.filename, + "content_type": file.content_type, + "image_summary": file.image_summary, + "image_analysis": file.image_analysis or {}, } - for row in rows + for file in files ] def fetch_uploaded_file( - conn: psycopg.Connection, + session: Session, user_id: str, image_id: str, ) -> dict[str, str] | None: - row = conn.execute( - """ - SELECT id, filename, content_type, storage_provider, storage_bucket, - COALESCE(storage_key, storage_path), image_summary, image_analysis - FROM uploaded_files - WHERE user_id = %s - AND id = %s - """, - (user_id, image_id), - ).fetchone() - if row is None: + file = session.scalar( + select(UploadedFile).where( + UploadedFile.user_id == user_id, UploadedFile.id == image_id + ) + ) + if file is None: return None return { - "id": row[0], - "filename": row[1], - "content_type": row[2], - "storage_provider": row[3], - "storage_bucket": row[4], - "storage_key": row[5], - "image_summary": row[6], - "image_analysis": row[7] or {}, + "id": file.id, + "filename": file.filename, + "content_type": file.content_type, + "storage_provider": file.storage_provider, + "storage_bucket": file.storage_bucket, + "storage_key": file.storage_key + if file.storage_key is not None + else file.storage_path, + "image_summary": file.image_summary, + "image_analysis": file.image_analysis or {}, } diff --git a/tests/component/claims/test_claim_lifecycle.py b/tests/component/claims/test_claim_lifecycle.py deleted file mode 100644 index b08d8b7..0000000 --- a/tests/component/claims/test_claim_lifecycle.py +++ /dev/null @@ -1,203 +0,0 @@ -from datetime import datetime, timezone - -import pytest - -from pydantic import ValidationError - -from supportagent.claims.schemas import ( - Claim, - ClaimDecisionRequest, - ClaimDocument, - ClaimReviewResponse, -) - - -class FakeConnection: - def commit(self) -> None: - pass - - def close(self) -> None: - pass - - -def claim(status: str = "DRAFT") -> Claim: - return Claim( - id="claim-1", - owner_user_id="user-1", - policy_id="POL-1", - product_line="residential_building", - policy_version="2026.1", - jurisdiction="DE", - customer_reference="CUSTOMER-1", - claim_type="water_damage", - status=status, - created_at="2026-07-21T00:00:00+00:00", - updated_at="2026-07-21T00:00:00+00:00", - ) - - -def document(document_type: str) -> ClaimDocument: - return ClaimDocument( - id=f"doc-{document_type}", - claim_id="claim-1", - document_type=document_type, - filename=f"{document_type}.pdf", - extraction_status="COMPLETED", - created_at="2026-07-21T00:00:00+00:00", - ) - - -def configure_service(monkeypatch, *, current_claim: Claim, documents: list[ClaimDocument]): - from supportagent.claims import service - - monkeypatch.setattr(service, "get_connection", lambda: FakeConnection()) - monkeypatch.setattr(service, "fetch_claim", lambda conn, claim_id, owner_user_id: current_claim) - monkeypatch.setattr(service, "fetch_documents", lambda conn, claim_id: documents) - monkeypatch.setattr(service, "add_audit_event", lambda *args, **kwargs: None) - monkeypatch.setattr( - service, - "update_claim_status", - lambda conn, claim_id, status: current_claim.model_copy(update={"status": status}), - ) - return service - - -def test_complete_draft_submission_becomes_ready_for_review(monkeypatch): - complete_documents = [ - document("claim_form"), - document("damage_cause_report"), - document("damage_photo"), - document("repair_invoice"), - ] - service = configure_service( - monkeypatch, - current_claim=claim(), - documents=complete_documents, - ) - - result = service.submit_claim_for_review("claim-1", "user-1") - - assert result.claim.status == "READY_FOR_REVIEW" - assert result.missing_documents == [] - - -def test_incomplete_draft_submission_becomes_documents_pending(monkeypatch): - service = configure_service( - monkeypatch, - current_claim=claim(), - documents=[document("claim_form")], - ) - - result = service.submit_claim_for_review("claim-1", "user-1") - - assert result.claim.status == "DOCUMENTS_PENDING" - assert result.missing_documents == [ - "damage_cause_report", - "damage_photo", - "repair_invoice", - ] - - -def test_review_can_only_start_when_claim_is_ready(monkeypatch): - from supportagent.claims import service - - configure_service(monkeypatch, current_claim=claim("DRAFT"), documents=[]) - - with pytest.raises(service.ClaimStateConflictError, match="not ready"): - service.start_claim_review_run("claim-1", "user-1") - - -def test_successful_supported_review_becomes_ready_for_decision(monkeypatch): - from supportagent.claims import service - - current_claim = claim("UNDER_REVIEW") - configure_service(monkeypatch, current_claim=current_claim, documents=[]) - transitions: list[str] = [] - monkeypatch.setattr( - service, - "update_claim_status", - lambda conn, claim_id, status: ( - transitions.append(status) or current_claim.model_copy(update={"status": status}) - ), - ) - completed_run = type( - "Run", - (), - { - "id": "run-1", - "claim_id": "claim-1", - "status": "SUCCEEDED", - "current_step": "COMPLETED", - "result": None, - "error": None, - "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), - "completed_at": datetime.now(timezone.utc).isoformat(), - }, - )() - monkeypatch.setattr(service, "update_review_run", lambda *args, **kwargs: completed_run) - - result = ClaimReviewResponse( - run_id="run-1", - claim_id="claim-1", - required_documents=[], - present_documents=[], - missing_documents=[], - optional_documents=[], - conditional_documents=[], - evidence_status="sufficient", - evidence_reason="Approved evidence retrieved.", - recommendation="Continue manual review.", - ) - - run = service.complete_claim_review_run(result, "user-1") - - assert run.status == "SUCCEEDED" - assert transitions == ["READY_FOR_DECISION"] - - -@pytest.mark.parametrize( - ("decision", "target_status"), - [("APPROVE", "APPROVED"), ("REJECT", "REJECTED")], -) -def test_human_decision_updates_claim_and_writes_audit( - monkeypatch, decision, target_status -): - current_claim = claim("READY_FOR_DECISION") - service = configure_service(monkeypatch, current_claim=current_claim, documents=[]) - transitions: list[str] = [] - audit_events: list[tuple[str, dict]] = [] - monkeypatch.setattr( - service, - "update_claim_status", - lambda conn, claim_id, status: ( - transitions.append(status) - or current_claim.model_copy(update={"status": status}) - ), - ) - monkeypatch.setattr( - service, - "add_audit_event", - lambda conn, claim_id, actor_user_id, event_type, payload: audit_events.append( - (event_type, payload) - ), - ) - - result = service.apply_human_claim_decision( - "claim-1", - "user-1", - ClaimDecisionRequest( - decision=decision, - reason="Reviewed by a human operator.", - ), - ) - - assert result.status == target_status - assert transitions == [target_status] - assert audit_events[-1][0] == "CLAIM_HUMAN_DECISION_RECORDED" - assert audit_events[-1][1]["decision_source"] == "HUMAN" - - -def test_rejecting_claim_requires_a_reason(): - with pytest.raises(ValidationError, match="reason is required"): - ClaimDecisionRequest(decision="REJECT") diff --git a/tests/integration/auth/test_auth_flows.py b/tests/integration/auth/test_auth_flows.py new file mode 100644 index 0000000..d02c1af --- /dev/null +++ b/tests/integration/auth/test_auth_flows.py @@ -0,0 +1,151 @@ +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi import HTTPException +from sqlalchemy import text + +from supportagent.auth.schemas import ( + ForgotPasswordRequest, + LoginRequest, + RegisterRequest, + ResetPasswordRequest, +) +from supportagent.auth.service import ( + login_user, + register_user, + request_password_reset, + reset_password, +) +from supportagent.auth.store import delete_session_token, get_user_by_session_token +from supportagent.db.session import engine + + +@pytest.fixture(autouse=True) +def _no_smtp(clean_db, monkeypatch): + monkeypatch.delenv("SMTP_HOST", raising=False) + monkeypatch.delenv("SMTP_FROM", raising=False) + + +def _register( + email="Ada@Example.com ", password="correct-horse", display_name=" Ada " +): + return register_user( + RegisterRequest(email=email, password=password, display_name=display_name) + ) + + +def _reset_token() -> str: + response = request_password_reset(ForgotPasswordRequest(email="ada@example.com")) + return parse_qs(urlparse(response.reset_url).query)["reset_token"][0] + + +def test_register_creates_user_and_logged_in_session(): + session = _register() + + user = get_user_by_session_token(session.token) + + assert user is not None + assert user.email == "ada@example.com" + assert user.display_name == "Ada" + assert user.id == session.user.id + + +def test_register_rejects_duplicate_email_case_insensitively(): + _register() + + with pytest.raises(HTTPException) as error: + _register(email="ADA@example.com") + + assert error.value.status_code == 409 + + +def test_login_returns_new_session_for_correct_password(): + registered = _register() + + session = login_user( + LoginRequest(email="ada@example.com", password="correct-horse") + ) + + assert session.token != registered.token + assert get_user_by_session_token(session.token).id == registered.user.id + + +@pytest.mark.parametrize( + ("email", "password"), + [("ada@example.com", "wrong-password"), ("nobody@example.com", "correct-horse")], +) +def test_login_rejects_bad_credentials(email, password): + _register() + + with pytest.raises(HTTPException) as error: + login_user(LoginRequest(email=email, password=password)) + + assert error.value.status_code == 401 + + +def test_logout_invalidates_session(): + session = _register() + + delete_session_token(session.token) + + assert get_user_by_session_token(session.token) is None + + +def test_expired_session_is_rejected(): + session = _register() + with engine.begin() as conn: + conn.execute( + text("UPDATE user_sessions SET expires_at = now() - interval '1 minute'") + ) + + assert get_user_by_session_token(session.token) is None + + +def test_password_reset_changes_password_and_revokes_sessions(): + old_session = _register() + + reset_password( + ResetPasswordRequest(token=_reset_token(), password="new-password-1") + ) + + assert get_user_by_session_token(old_session.token) is None + with pytest.raises(HTTPException): + login_user(LoginRequest(email="ada@example.com", password="correct-horse")) + assert login_user(LoginRequest(email="ada@example.com", password="new-password-1")) + + +def test_password_reset_token_can_only_be_used_once(): + _register() + token = _reset_token() + reset_password(ResetPasswordRequest(token=token, password="new-password-1")) + + with pytest.raises(HTTPException) as error: + reset_password(ResetPasswordRequest(token=token, password="new-password-2")) + + assert error.value.status_code == 400 + + +def test_expired_password_reset_token_is_rejected(): + _register() + token = _reset_token() + with engine.begin() as conn: + conn.execute( + text( + "UPDATE password_reset_tokens SET expires_at = now() - interval '1 minute'" + ) + ) + + with pytest.raises(HTTPException) as error: + reset_password(ResetPasswordRequest(token=token, password="new-password-1")) + + assert error.value.status_code == 400 + + +def test_password_reset_request_for_unknown_email_reveals_nothing(): + _register() + + unknown = request_password_reset(ForgotPasswordRequest(email="nobody@example.com")) + known = request_password_reset(ForgotPasswordRequest(email="ada@example.com")) + + assert unknown.reset_url is None + assert unknown.message == known.message diff --git a/tests/integration/claims/test_claim_service.py b/tests/integration/claims/test_claim_service.py new file mode 100644 index 0000000..6bc535c --- /dev/null +++ b/tests/integration/claims/test_claim_service.py @@ -0,0 +1,277 @@ +import pytest + +from supportagent.claims.schemas import ( + ClaimDecisionRequest, + ClaimReviewResponse, + CreateClaimDocumentRequest, + CreateClaimRequest, + CreateProposedActionRequest, +) +from supportagent.claims.service import ( + ClaimActionConflictError, + ClaimNotFoundError, + ClaimStateConflictError, + apply_human_claim_decision, + approve_action, + complete_claim_review_run, + create_claim, + create_claim_document, + create_proposed_action, + fail_claim_review_run, + get_claim, + get_claim_review_run, + list_claims, + record_claim_review_progress, + reject_action, + start_claim_review_run, + submit_claim_for_review, +) + +COMPLETE_DOCUMENTS = ["claim_form", "damage_cause_report", "damage_photo", "repair_invoice"] + + +@pytest.fixture(autouse=True) +def _clean(clean_db): + pass + + +def new_claim(owner="alice"): + return create_claim( + owner, + CreateClaimRequest( + policy_id="POL-1", + product_line="residential_building", + customer_reference="CUSTOMER-1", + claim_type="water_damage", + ), + ) + + +def add_documents(claim_id, document_types, owner="alice"): + for document_type in document_types: + create_claim_document( + claim_id, + owner, + CreateClaimDocumentRequest( + document_type=document_type, + filename=f"{document_type}.pdf", + extraction_status="COMPLETED", + extracted_fields={"source": document_type}, + ), + ) + + +def ready_for_review_claim(): + claim = new_claim() + add_documents(claim.id, COMPLETE_DOCUMENTS) + submit_claim_for_review(claim.id, "alice") + return claim + + +def review_result(run, missing_documents=()): + return ClaimReviewResponse( + run_id=run.id, + claim_id=run.claim_id, + required_documents=COMPLETE_DOCUMENTS, + present_documents=COMPLETE_DOCUMENTS, + missing_documents=list(missing_documents), + optional_documents=[], + conditional_documents=[], + evidence_status="sufficient", + evidence_reason="All required documents are present.", + recommendation="Ready for a human decision.", + ) + + +def ready_for_decision_claim(): + claim = ready_for_review_claim() + run = start_claim_review_run(claim.id, "alice") + complete_claim_review_run(review_result(run), "alice") + return claim + + +def event_types(claim_id): + return {event.event_type for event in get_claim(claim_id, "alice").audit_events} + + +def update_status_action(claim_id, owner="alice"): + return create_proposed_action( + claim_id, + owner, + CreateProposedActionRequest( + action_type="UPDATE_CLAIM_STATUS", + tool_server="claims", + tool_name="update_claim_status", + arguments={"status": "APPROVED"}, + reason="Documents complete.", + ), + ) + + +# create and read +def test_new_claim_starts_as_draft_and_is_audited(): + claim = new_claim() + + detail = get_claim(claim.id, "alice") + + assert detail.claim.status == "DRAFT" + assert detail.claim.jurisdiction == "DE" + assert "CLAIM_CREATED" in event_types(claim.id) + + +def test_claims_are_only_visible_to_owner(): + claim = new_claim() + + assert [c.id for c in list_claims("alice")] == [claim.id] + assert list_claims("bob") == [] + with pytest.raises(ClaimNotFoundError): + get_claim(claim.id, "bob") + + +def test_other_user_cannot_add_documents(): + claim = new_claim() + + with pytest.raises(ClaimNotFoundError): + add_documents(claim.id, ["claim_form"], owner="bob") + assert get_claim(claim.id, "alice").documents == [] + + +# submission +def test_submission_with_missing_documents_waits_for_documents(): + claim = new_claim() + add_documents(claim.id, ["claim_form"]) + + result = submit_claim_for_review(claim.id, "alice") + + assert result.claim.status == "DOCUMENTS_PENDING" + assert "damage_photo" in result.missing_documents + + +def test_complete_submission_is_ready_for_review(): + claim = new_claim() + add_documents(claim.id, COMPLETE_DOCUMENTS) + + result = submit_claim_for_review(claim.id, "alice") + + assert result.claim.status == "READY_FOR_REVIEW" + assert result.missing_documents == [] + documents = get_claim(claim.id, "alice").documents + assert documents[0].extracted_fields == {"source": "claim_form"} + + +# review runs +def test_review_run_moves_claim_to_decision(): + claim = ready_for_review_claim() + + run = start_claim_review_run(claim.id, "alice") + assert run.status == "RUNNING" + assert get_claim(claim.id, "alice").claim.status == "UNDER_REVIEW" + + record_claim_review_progress(claim.id, run.id, "alice", "CHECK_DOCUMENTS") + assert get_claim_review_run(claim.id, run.id, "alice").current_step == "CHECK_DOCUMENTS" + + finished = complete_claim_review_run(review_result(run), "alice") + + assert finished.status == "SUCCEEDED" + assert finished.completed_at is not None + assert finished.result.evidence_status == "sufficient" + assert get_claim(claim.id, "alice").claim.status == "READY_FOR_DECISION" + + +def test_review_with_missing_documents_needs_information(): + claim = ready_for_review_claim() + run = start_claim_review_run(claim.id, "alice") + + complete_claim_review_run(review_result(run, missing_documents=["repair_invoice"]), "alice") + + assert get_claim(claim.id, "alice").claim.status == "NEEDS_INFORMATION" + + +def test_second_review_cannot_start_while_one_is_running(): + claim = ready_for_review_claim() + start_claim_review_run(claim.id, "alice") + + with pytest.raises(ClaimStateConflictError): + start_claim_review_run(claim.id, "alice") + + +def test_failed_review_can_be_retried(): + claim = ready_for_review_claim() + run = start_claim_review_run(claim.id, "alice") + + fail_claim_review_run(claim.id, run.id, "alice", "LLM timeout") + + failed = get_claim_review_run(claim.id, run.id, "alice") + assert (failed.status, failed.error) == ("FAILED", "LLM timeout") + assert get_claim(claim.id, "alice").claim.status == "READY_FOR_REVIEW" + retry = start_claim_review_run(claim.id, "alice") + assert get_claim(claim.id, "alice").latest_review_run.id == retry.id + + +def test_other_user_cannot_read_review_run(): + claim = ready_for_review_claim() + run = start_claim_review_run(claim.id, "alice") + + with pytest.raises(ClaimNotFoundError): + get_claim_review_run(claim.id, run.id, "bob") + + +# human decision +def test_human_decision_approves_claim(): + claim = ready_for_decision_claim() + + updated = apply_human_claim_decision( + claim.id, "alice", ClaimDecisionRequest(decision="APPROVE") + ) + + assert updated.status == "APPROVED" + assert "CLAIM_HUMAN_DECISION_RECORDED" in event_types(claim.id) + + +# proposed actions +def test_proposed_action_waits_for_approval(): + claim = new_claim() + + action = update_status_action(claim.id) + + assert (action.status, action.risk_level) == ("WAITING_FOR_APPROVAL", "high") + assert action.arguments == {"status": "APPROVED"} + + +def test_approving_action_records_approver(): + claim = new_claim() + action = update_status_action(claim.id) + + approved = approve_action(claim.id, action.id, "alice", "Looks good") + + assert approved.status == "APPROVED" + assert approved.approved_by == "alice" + assert approved.approved_at is not None + assert "ACTION_APPROVED" in event_types(claim.id) + + +def test_rejecting_action_clears_approver(): + claim = new_claim() + action = update_status_action(claim.id) + + rejected = reject_action(claim.id, action.id, "alice", "Not yet") + + assert rejected.status == "REJECTED" + assert rejected.approved_by is None + + +def test_action_cannot_be_decided_twice(): + claim = new_claim() + action = update_status_action(claim.id) + approve_action(claim.id, action.id, "alice") + + with pytest.raises(ClaimActionConflictError): + reject_action(claim.id, action.id, "alice") + + +def test_other_user_cannot_approve_action(): + claim = new_claim() + action = update_status_action(claim.id) + + with pytest.raises(ClaimNotFoundError): + approve_action(claim.id, action.id, "bob") diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..3f9a5b1 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,52 @@ +import os +from pathlib import Path + +import psycopg +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import text + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +TEST_DATABASE_URL = os.environ.get( + "TEST_DATABASE_URL", + "postgresql://supportagent:supportagent@localhost:5433/supportagent_test", +) +os.environ["DATABASE_URL"] = TEST_DATABASE_URL + + +def _admin_url() -> str: + return TEST_DATABASE_URL.rsplit("/", 1)[0] + "/postgres" + + +@pytest.fixture(scope="session") +def database(): + db_name = TEST_DATABASE_URL.rsplit("/", 1)[1] + assert db_name.endswith("_test"), ( + f"refusing to recreate non-test database {db_name!r}" + ) + + with psycopg.connect(_admin_url(), autocommit=True) as admin: + admin.execute(f'DROP DATABASE IF EXISTS "{db_name}"') + admin.execute(f'CREATE DATABASE "{db_name}"') + + alembic_config = Config(str(PROJECT_ROOT / "alembic.ini")) + command.upgrade(alembic_config, "head") + yield alembic_config + + from supportagent.db.session import engine + + engine.dispose() + with psycopg.connect(_admin_url(), autocommit=True) as admin: + admin.execute(f'DROP DATABASE "{db_name}" WITH (FORCE)') + + +@pytest.fixture +def clean_db(database): + yield + from supportagent.db.base import Base + from supportagent.db.session import engine + + tables = ", ".join(table.name for table in Base.metadata.sorted_tables) + with engine.begin() as conn: + conn.execute(text(f"TRUNCATE {tables} RESTART IDENTITY CASCADE")) diff --git a/tests/integration/db/test_migrations.py b/tests/integration/db/test_migrations.py new file mode 100644 index 0000000..27d223f --- /dev/null +++ b/tests/integration/db/test_migrations.py @@ -0,0 +1,35 @@ +from alembic import command +from sqlalchemy import func, inspect, select, text + +from supportagent.db.base import Base +from supportagent.db.models.mcp import McpServer, McpToolPolicy +from supportagent.db.session import SessionLocal, engine + +BASELINE = "863e47af17d2" + + +def test_migrations_create_every_model_table(database): + tables = set(inspect(engine).get_table_names()) + assert set(Base.metadata.tables) <= tables + + +def test_models_and_migrations_are_in_sync(database): + command.check(database) + + +def test_seed_migration_adds_default_mcp_servers_and_policies(database, clean_db): + command.downgrade(database, BASELINE) + with engine.begin() as conn: + conn.execute(text("DELETE FROM mcp_servers")) + command.upgrade(database, "head") + + with SessionLocal() as session: + servers = session.scalars( + select(McpServer.server_name).order_by(McpServer.server_name) + ).all() + policy_count = session.scalar(select(func.count()).select_from(McpToolPolicy)) + create_message = session.get(McpToolPolicy, ("teams_mcp", "create_message")) + + assert servers == ["teams_mcp", "time_mcp", "weather_mcp"] + assert policy_count == 21 + assert create_message.enabled is False diff --git a/tests/integration/mcp/test_mcp_store.py b/tests/integration/mcp/test_mcp_store.py new file mode 100644 index 0000000..e373e81 --- /dev/null +++ b/tests/integration/mcp/test_mcp_store.py @@ -0,0 +1,181 @@ +import pytest + + +from supportagent.mcp_client.store import ( + MCPIdempotencyClaim, + abandon_idempotency_key, + add_audit_log, + claim_idempotency_key, + complete_idempotency_key, + consume_oauth_state, + get_tool_policy, + get_user_credentials, + list_audit_logs, + list_mcp_servers, + save_oauth_state, + upsert_mcp_server, + upsert_user_credentials, +) + +pytestmark = pytest.mark.usefixtures("clean_db") + +KEY = { + "user_id": "user-1", + "server_name": "calendar", + "tool_name": "create_event", + "idempotency_key": "req-1", +} + + +def add_server(**overrides): + server = { + "server_name": "test_mcp", + "display_name": "Test MCP", + "transport": "stdio", + "config": {"command": "python"}, + "enabled": True, + } + upsert_mcp_server(**{**server, **overrides}) + + +def listed_server(server_name): + return {s["server_name"]: s for s in list_mcp_servers()}[server_name] + + +# db idempotent +def test_first_claim_executes(): + assert claim_idempotency_key(**KEY).status == "execute" + + +def test_claim_while_in_progress_does_not_execute(): + claim_idempotency_key(**KEY) + + assert claim_idempotency_key(**KEY).status == "in_progress" + + +def test_completed_key_replays_result(): + claim_idempotency_key(**KEY) + complete_idempotency_key(**KEY, result="event-123") + + assert claim_idempotency_key(**KEY) == MCPIdempotencyClaim( + status="replay", result="event-123" + ) + + +def test_abandoned_key_can_be_claimed_again(): + claim_idempotency_key(**KEY) + abandon_idempotency_key(**KEY) + + assert claim_idempotency_key(**KEY).status == "execute" + + +def test_abandon_keeps_completed_key(): + claim_idempotency_key(**KEY) + complete_idempotency_key(**KEY, result="event-123") + abandon_idempotency_key(**KEY) + + assert claim_idempotency_key(**KEY).status == "replay" + + +def test_key_is_scoped_per_user(): + claim_idempotency_key(**KEY) + + other_user = {**KEY, "user_id": "user-2"} + assert claim_idempotency_key(**other_user).status == "execute" + + +# OAuth state +def test_oauth_state_can_only_be_used_once(): + save_oauth_state("state-1", "user-1", "microsoft") + + assert consume_oauth_state("state-1", "microsoft") == "user-1" + assert consume_oauth_state("state-1", "microsoft") is None + + +def test_oauth_state_is_bound_to_provider(): + save_oauth_state("state-1", "user-1", "microsoft") + + assert consume_oauth_state("state-1", "google") is None + + +def test_expired_oauth_state_is_rejected(): + save_oauth_state("state-1", "user-1", "microsoft", ttl_minutes=-1) + + assert consume_oauth_state("state-1", "microsoft") is None + + +def test_saving_same_oauth_state_overwrites_it(): + save_oauth_state("state-1", "user-1", "microsoft") + save_oauth_state("state-1", "user-2", "microsoft") + + assert consume_oauth_state("state-1", "microsoft") == "user-2" + + +# servers +def test_added_server_is_listed(): + add_server() + + server = listed_server("test_mcp") + assert server["config"] == {"command": "python"} + assert server["enabled"] is True + + +def test_upserting_existing_server_updates_it(): + add_server() + add_server(display_name="Renamed", enabled=False) + + server = listed_server("test_mcp") + assert server["display_name"] == "Renamed" + assert server["enabled"] is False + + +# tool policy +def test_unknown_write_tool_requires_confirmation(): + policy = get_tool_policy("test_mcp", "delete_everything") + + assert policy.enabled is True + assert policy.auto_allowed is False + assert policy.requires_confirmation is True + + +# credentials +def test_saved_credentials_are_returned(): + add_server() + upsert_user_credentials("user-1", "test_mcp", {"access_token": "tok-1"}) + + assert get_user_credentials("user-1", "test_mcp") == {"access_token": "tok-1"} + + +def test_saving_credentials_again_replaces_them(): + add_server() + upsert_user_credentials("user-1", "test_mcp", {"access_token": "tok-1"}) + upsert_user_credentials("user-1", "test_mcp", {"access_token": "tok-2"}) + + assert get_user_credentials("user-1", "test_mcp") == {"access_token": "tok-2"} + + +def test_missing_credentials_are_empty(): + assert get_user_credentials("user-1", "test_mcp") == {} + + +# audit log +def test_audit_log_redacts_secrets(): + add_audit_log( + "user-1", + "test_mcp", + "get_weather", + "success", + {"city": "Berlin", "access_token": "secret"}, + ) + + [record] = list_audit_logs("user-1") + assert record.arguments == {"city": "Berlin", "access_token": "***"} + + +def test_audit_logs_are_newest_first_and_per_user(): + add_audit_log("user-1", "test_mcp", "get_weather", "success", {}) + add_audit_log("user-1", "test_mcp", "get_current_time", "success", {}) + add_audit_log("user-2", "test_mcp", "get_weather", "success", {}) + + tools = [record.tool_name for record in list_audit_logs("user-1")] + assert tools == ["get_current_time", "get_weather"] diff --git a/tests/integration/memory/test_memory_service.py b/tests/integration/memory/test_memory_service.py new file mode 100644 index 0000000..8272ef3 --- /dev/null +++ b/tests/integration/memory/test_memory_service.py @@ -0,0 +1,154 @@ +import pytest + +from supportagent.memory import service +from supportagent.memory.service import ( + delete_thread_user_turn, + get_thread_messages, + list_conversation_threads, + load_memory_context, + record_mcp_action_turn, + save_memory_turn, + update_thread_user_message, +) + +WATER = [1.0, 0.0] + [0.0] * 1022 +OTHER = [0.0, 1.0] + [0.0] * 1022 + + +def fake_embed_texts(_client, texts): + return [WATER if "Wasser" in text else OTHER for text in texts] + + +@pytest.fixture(autouse=True) +def _fake_embeddings(clean_db, monkeypatch): + monkeypatch.setattr(service, "get_embedding_client", lambda: None) + monkeypatch.setattr(service, "embed_texts", fake_embed_texts) + + +def turns(messages): + return [(message.role, message.content) for message in messages] + + +# short memory +def test_saved_turn_becomes_short_memory(): + save_memory_turn("t-1", "alice", "Was ist Hausrat?", "Hausrat ist ...") + + short_memory, _ = load_memory_context("t-1", "alice", "Und weiter?") + + assert turns(short_memory) == [ + ("user", "Was ist Hausrat?"), + ("assistant", "Hausrat ist ..."), + ] + + +def test_short_memory_keeps_last_six_messages_oldest_first(): + for n in range(1, 6): + save_memory_turn("t-1", "alice", f"q{n}", f"a{n}") + + short_memory, _ = load_memory_context("t-1", "alice", "Und weiter?") + + assert [message.content for message in short_memory] == [ + "q3", + "a3", + "q4", + "a4", + "q5", + "a5", + ] + + +# long memory +def test_most_relevant_long_memory_comes_first(): + save_memory_turn("t-1", "alice", "Kfz-Schaden melden?", "Online melden.") + save_memory_turn("t-2", "alice", "Wasserschaden melden?", "Fotos senden.") + + _, long_memories = load_memory_context("t-3", "alice", "Wasser im Keller") + + assert "Wasserschaden" in long_memories[0].content + + +def test_long_memory_is_scoped_per_user(): + save_memory_turn("t-1", "alice", "Wasserschaden melden?", "Fotos senden.") + + _, long_memories = load_memory_context("t-2", "bob", "Wasser im Keller") + + assert long_memories == [] + + +def test_mcp_action_turn_is_saved_without_long_memory(): + record_mcp_action_turn("t-1", "alice", "Termin anlegen", "Termin erstellt.") + + _, long_memories = load_memory_context("t-2", "alice", "Termin") + + assert turns(get_thread_messages("t-1", "alice")) == [ + ("user", "Termin anlegen"), + ("assistant", "Termin erstellt."), + ] + assert long_memories == [] + + +# threads +def test_thread_messages_are_only_visible_to_owner(): + save_memory_turn("t-1", "alice", "Frage", "Antwort") + + assert len(get_thread_messages("t-1", "alice")) == 2 + assert get_thread_messages("t-1", "bob") == [] + + +def test_threads_are_listed_newest_first_for_owner(): + save_memory_turn("t-1", "alice", "Erste Frage", "Antwort") + save_memory_turn("t-2", "alice", "Zweite Frage", "Antwort") + save_memory_turn("t-3", "bob", "Bobs Frage", "Antwort") + + threads = list_conversation_threads("alice") + + assert [thread.thread_id for thread in threads] == ["t-2", "t-1"] + assert threads[0].title == "Zweite Frage" + assert threads[0].message_count == 2 + + +# edit and delete +def test_editing_question_removes_its_answer(): + save_memory_turn("t-1", "alice", "Frage", "Antwort") + [question, _] = get_thread_messages("t-1", "alice") + + assert update_thread_user_message("t-1", "alice", question.id, "Neue Frage") + assert turns(get_thread_messages("t-1", "alice")) == [("user", "Neue Frage")] + + +def test_other_user_cannot_edit_question(): + save_memory_turn("t-1", "alice", "Frage", "Antwort") + [question, _] = get_thread_messages("t-1", "alice") + + assert not update_thread_user_message("t-1", "bob", question.id, "Gehackt") + assert len(get_thread_messages("t-1", "alice")) == 2 + + +def test_deleting_question_removes_whole_turn(): + save_memory_turn("t-1", "alice", "Frage", "Antwort") + [question, _] = get_thread_messages("t-1", "alice") + + assert delete_thread_user_turn("t-1", "alice", question.id) + assert get_thread_messages("t-1", "alice") == [] + + +# thread ownership +def test_other_user_cannot_read_thread_via_short_memory(): + save_memory_turn("t-1", "alice", "Meine Policennummer ist 123", "Notiert.") + short_memory, _ = load_memory_context("t-1", "bob", "Was weißt du?") + assert short_memory == [] + + +def test_other_user_cannot_take_over_thread(): + save_memory_turn("t-1", "alice", "Frage", "Antwort") + + save_memory_turn("t-1", "bob", "Bobs Frage", "Bobs Antwort") + + assert [thread.thread_id for thread in list_conversation_threads("alice")] == [ + "t-1" + ] + assert list_conversation_threads("bob") == [] + assert turns(get_thread_messages("t-1", "alice")) == [ + ("user", "Frage"), + ("assistant", "Antwort"), + ] diff --git a/tests/integration/rag/test_rag_index_and_retrieval.py b/tests/integration/rag/test_rag_index_and_retrieval.py new file mode 100644 index 0000000..1f3e000 --- /dev/null +++ b/tests/integration/rag/test_rag_index_and_retrieval.py @@ -0,0 +1,107 @@ +import pytest +from sqlalchemy import text + +from supportagent.core.models import Document +from supportagent.db.session import SessionLocal +from supportagent.rag import index, retrieval +from supportagent.rag.retrieval import retrieve + +WATER = [1.0, 0.0] + [0.0] * 1022 +CAR = [0.0, 1.0] + [0.0] * 1022 + + +def fake_embed_texts(_client, texts): + return [WATER if "Wasser" in text else CAR for text in texts] + + +@pytest.fixture(autouse=True) +def _fake_embeddings(clean_db, monkeypatch): + for module in (index, retrieval): + monkeypatch.setattr(module, "get_embedding_client", lambda: None) + monkeypatch.setattr(module, "embed_texts", fake_embed_texts) + + +def confluence_page(source_id, title, section): + return Document( + text=f"{title}\n\n{section}", + metadata={"source": "confluence", "source_id": source_id, "title": title}, + ) + + +def jira_issue(source_id, summary): + return Document( + text=summary, + metadata={"source": "jira", "source_id": source_id, "title": summary}, + ) + + +def index_documents(monkeypatch, documents): + monkeypatch.setattr(index, "collect_documents", lambda: documents) + index.main() + + +def test_closest_chunk_comes_first(monkeypatch): + index_documents( + monkeypatch, + [ + confluence_page("page-1", "Wasserschaden", "Fotos senden."), + jira_issue("issue-1", "Autounfall melden"), + ], + ) + + chunks = retrieve("Wasser im Keller") + + assert [chunk["document_id"] for chunk in chunks] == ["page-1", "issue-1"] + assert chunks[0]["content"] == "Wasserschaden\n\nFotos senden." + assert chunks[0]["metadata"]["source"] == "confluence" + assert chunks[0]["distance"] == pytest.approx(0.0) + + +def test_reindexing_updates_chunk_instead_of_duplicating(monkeypatch): + index_documents(monkeypatch, [confluence_page("page-1", "Wasserschaden", "Alt.")]) + index_documents(monkeypatch, [confluence_page("page-1", "Wasserschaden", "Neu.")]) + + chunks = retrieve("Wasser") + + assert [chunk["content"] for chunk in chunks] == ["Wasserschaden\n\nNeu."] + + +def test_source_filter_only_returns_that_source(monkeypatch): + index_documents( + monkeypatch, + [ + confluence_page("page-1", "Wasserschaden", "Fotos senden."), + jira_issue("issue-1", "Wasserrohrbruch"), + ], + ) + + chunks = retrieve("Wasser", source_filter="jira") + + assert [chunk["document_id"] for chunk in chunks] == ["issue-1"] + + +def test_limit_caps_number_of_chunks(monkeypatch): + index_documents( + monkeypatch, + [jira_issue(f"issue-{n}", f"Autounfall {n}") for n in range(3)], + ) + + assert len(retrieve("Auto", limit=2)) == 2 + + +def test_cosine_search_can_use_hnsw_index(): + with SessionLocal() as session: + session.execute(text("SET LOCAL enable_seqscan = off")) + plan = ( + session.execute( + text( + "EXPLAIN SELECT id FROM chunks " + "ORDER BY embedding <=> CAST(:q AS vector) LIMIT 5" + ), + {"q": str(WATER)}, + ) + .scalars() + .all() + ) + + assert any("chunks_embedding_hnsw_idx" in line for line in plan) diff --git a/tests/integration/uploads/test_upload_store.py b/tests/integration/uploads/test_upload_store.py new file mode 100644 index 0000000..29cf228 --- /dev/null +++ b/tests/integration/uploads/test_upload_store.py @@ -0,0 +1,98 @@ +import pytest +from sqlalchemy import text + +from supportagent.db.session import SessionLocal +from supportagent.uploads.store import ( + create_uploaded_file, + fetch_uploaded_file, + fetch_uploaded_files, +) + + +@pytest.fixture +def conn(clean_db): + session = SessionLocal() + yield session + session.rollback() + session.close() + + +def _create(conn, file_id, user_id="user-1", **overrides): + values = { + "file_id": file_id, + "user_id": user_id, + "thread_id": "thread-1", + "filename": f"{file_id}.png", + "content_type": "image/png", + "storage_provider": "local", + "storage_bucket": "local", + "storage_key": f"users/{user_id}/{file_id}.png", + "image_summary": "a damaged bumper", + "image_analysis": {"ocr_text": "INVOICE 42"}, + } + values.update(overrides) + create_uploaded_file(conn, **values) + + +def test_fetch_uploaded_file_returns_stored_record(conn): + _create(conn, "img-1") + + record = fetch_uploaded_file(conn, user_id="user-1", image_id="img-1") + + assert record == { + "id": "img-1", + "filename": "img-1.png", + "content_type": "image/png", + "storage_provider": "local", + "storage_bucket": "local", + "storage_key": "users/user-1/img-1.png", + "image_summary": "a damaged bumper", + "image_analysis": {"ocr_text": "INVOICE 42"}, + } + + +def test_fetch_uploaded_file_hides_other_users_files(conn): + _create(conn, "img-1", user_id="owner") + + assert fetch_uploaded_file(conn, user_id="someone-else", image_id="img-1") is None + + +def test_missing_image_analysis_is_stored_as_empty_object(conn): + _create(conn, "img-1", image_analysis=None) + + record = fetch_uploaded_file(conn, user_id="user-1", image_id="img-1") + + assert record["image_analysis"] == {} + + +def test_legacy_rows_fall_back_to_storage_path(conn): + conn.execute( + text( + """ + INSERT INTO uploaded_files + (id, user_id, filename, content_type, storage_path, image_summary) + VALUES ('legacy', 'user-1', 'old.png', 'image/png', '/data/old.png', 'old') + """ + ) + ) + + record = fetch_uploaded_file(conn, user_id="user-1", image_id="legacy") + + assert record["storage_key"] == "/data/old.png" + + +def test_fetch_uploaded_files_filters_by_user_and_orders_by_upload_time(conn): + _create(conn, "newer") + _create(conn, "older") + _create(conn, "foreign", user_id="user-2") + conn.execute( + text( + "UPDATE uploaded_files SET created_at = now() - interval '1 hour' WHERE id = 'older'" + ) + ) + + rows = fetch_uploaded_files( + conn, user_id="user-1", image_ids=["newer", "older", "foreign", "missing"] + ) + + assert [row["id"] for row in rows] == ["older", "newer"] diff --git a/tests/unit/claims/test_claim_decision_request.py b/tests/unit/claims/test_claim_decision_request.py new file mode 100644 index 0000000..2a9a1e1 --- /dev/null +++ b/tests/unit/claims/test_claim_decision_request.py @@ -0,0 +1,9 @@ +import pytest +from pydantic import ValidationError + +from supportagent.claims.schemas import ClaimDecisionRequest + + +def test_rejecting_claim_requires_a_reason(): + with pytest.raises(ValidationError, match="reason is required"): + ClaimDecisionRequest(decision="REJECT") diff --git a/tests/unit/claims/test_claim_store.py b/tests/unit/claims/test_claim_store.py deleted file mode 100644 index 2e66745..0000000 --- a/tests/unit/claims/test_claim_store.py +++ /dev/null @@ -1,44 +0,0 @@ -from datetime import datetime, timezone - -from psycopg.types.json import Jsonb - -from supportagent.claims.store import update_review_run - - -class RecordingCursor: - def __init__(self, row): - self.row = row - - def fetchone(self): - return self.row - - -class RecordingConnection: - def __init__(self): - self.query = "" - self.params = () - - def execute(self, query, params): - self.query = query - self.params = params - now = datetime.now(timezone.utc) - return RecordingCursor( - ("run-1", "claim-1", "SUCCEEDED", "COMPLETED", None, None, now, now, now) - ) - - -def test_review_result_is_written_as_jsonb(): - conn = RecordingConnection() - - update_review_run( - conn, - "run-1", - status="SUCCEEDED", - current_step="COMPLETED", - result={"claim_id": "claim-1"}, - complete=True, - ) - - assert "result = COALESCE(%s, result)" in conn.query - assert isinstance(conn.params[2], Jsonb) - assert conn.params[2].obj == {"claim_id": "claim-1"} diff --git a/uv.lock b/uv.lock index 89f52f1..7a46914 100644 --- a/uv.lock +++ b/uv.lock @@ -1,14 +1,25 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version < '3.11'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, ] [[package]] @@ -53,7 +64,6 @@ name = "anyio" version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -152,18 +162,6 @@ dependencies = [ ] 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/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, - { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, - { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, - { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, - { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, { 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" }, @@ -259,22 +257,6 @@ version = "3.4.7" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, @@ -385,7 +367,6 @@ version = "49.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] 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 = [ @@ -454,18 +435,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "fastapi" version = "0.137.0" @@ -564,19 +533,6 @@ version = "0.15.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/da/76a2c7e510ba15fe323d9509c223ab272da79ea59f54488f4a78da6426db/jiter-0.15.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:edebcf7d1f601199084bb6e844d7dc67e03e04f6ac786b0332d616635c4ff7a4", size = 310849, upload-time = "2026-05-19T10:06:51.944Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8e/827be942883a4dc0862c48626ff41af3320b1902d136a0bf4b9041f2c567/jiter-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f924585cdacf631cd382b657966847bb537bf9ed0a6f9b991da5f05a631480f", size = 314991, upload-time = "2026-05-19T10:06:53.522Z" }, - { url = "https://files.pythonhosted.org/packages/6d/38/be2832be361ba1b9517c76f46d30b64e985be1dd43c974f4c3a4b1844436/jiter-0.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abbf258599526ad0326fe51e252e24f2bd6f24f1852681b4b78feda3808f1d18", size = 340843, upload-time = "2026-05-19T10:06:55.071Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d8/90f01fb83c0c7ba509303ec93e32a308fbfa167d264860b01c0fd0dbbd06/jiter-0.15.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c468136b8bd6bb18c8786e4236a1fa27362f24cb23450ba0cb204ab379b8e6f", size = 365116, upload-time = "2026-05-19T10:06:56.893Z" }, - { url = "https://files.pythonhosted.org/packages/91/38/94593d34f8c67a0b6f6cbc027f016ffa9780b3a858a7a86f6fd7a15bcc1e/jiter-0.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05906b93d72f03339e6bb7cf8dc10ebda64a0266126eed6beba79e20abcf5fd4", size = 457970, upload-time = "2026-05-19T10:06:58.707Z" }, - { url = "https://files.pythonhosted.org/packages/df/04/d79962dd49d00c97e2a9b4cacea1947904d02135936960351f9a96d4c1a6/jiter-0.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:30ce785d2adb8e32c3f7741442370a74834ec4c01f3c48f0750227a0b4ef27d6", size = 375744, upload-time = "2026-05-19T10:07:00.471Z" }, - { url = "https://files.pythonhosted.org/packages/c3/2e/5d37abe2be0e819c21e2338bebd410e481763ce526a9138c8c3652fa0123/jiter-0.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fd73e3da91a0a722d67165e849ce2cdc10de0e0d48738c142be8c6c5f310f4c", size = 349609, upload-time = "2026-05-19T10:07:01.829Z" }, - { url = "https://files.pythonhosted.org/packages/7a/90/98768ad2ed90c1fda15d64157de2dfbf73c1c074d4b1bfaca915480bc7cf/jiter-0.15.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:ceb8fc27d38793f9c97149be8302720c5b22e5c195a37bf2c45dc36c4600a512", size = 354366, upload-time = "2026-05-19T10:07:03.587Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c4/fbfb806209f1fe4b7dccdfb07bc62bb044300734a945b06fd64db446ef6a/jiter-0.15.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d726e3ceeb337191324b49de298142f27c3ad10886341555d1d5315b5f252c6a", size = 393519, upload-time = "2026-05-19T10:07:05.08Z" }, - { url = "https://files.pythonhosted.org/packages/37/1c/b9c257cd70cb453b6d10f3ebf0402cdb11669ab455389096f09839670290/jiter-0.15.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2c8aea7781d2a372227871de4e1a1332aa96f5a89fd76c5e835dafdbad102887", size = 519952, upload-time = "2026-05-19T10:07:06.589Z" }, - { url = "https://files.pythonhosted.org/packages/a9/1a/aa85027db7ab15829c12feebbc33b404f53fc399bd559d85fd0d6365ff0d/jiter-0.15.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf4bd113a69c0a740e27cb962ce10630c36d2b8f59d759a651b955ee9d18a823", size = 550770, upload-time = "2026-05-19T10:07:08.228Z" }, - { url = "https://files.pythonhosted.org/packages/d4/54/8c3f65c8a5687925e84708f19d63f7f37d28e2b86a48d951702ad94424d8/jiter-0.15.0-cp310-cp310-win32.whl", hash = "sha256:d92a5cd21fdb083931d546c207aa29633787c5dc5b02daab2d32b843f88a2c53", size = 209303, upload-time = "2026-05-19T10:07:10.006Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/0528a1eb9f42dd2d8228a0711458628f35924d131f623eaebc35fd23d3d4/jiter-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:e58585a58209d72691ce2d62a9147445f5a87beb0bde97fde284c96ae392a3d1", size = 200404, upload-time = "2026-05-19T10:07:11.426Z" }, { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, @@ -699,8 +655,7 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -896,6 +851,146 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/62/6339eae6b8c9ec941b06dc09fe05e97f586e91d2af4378a428070bab8d5d/langsmith-0.10.3-py3-none-any.whl", hash = "sha256:40fe55aab588ba5eddd462c9710ac10754ed0530366f1605969e054cbe03f8ca", size = 654001, upload-time = "2026-07-14T09:12:24.671Z" }, ] +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + [[package]] name = "mcp" version = "1.28.1" @@ -921,83 +1016,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] -[[package]] -name = "numpy" -version = "2.2.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, -] - [[package]] name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, @@ -1179,19 +1201,6 @@ version = "3.11.9" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" }, - { url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" }, - { url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" }, - { url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" }, - { url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" }, - { url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" }, - { url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" }, - { url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" }, { url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" }, { url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" }, { url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" }, @@ -1260,14 +1269,6 @@ version = "1.12.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/fa/a91f70829ebccf6387c4946e0a1a109f6ba0d6a28d65f628bedfad94b890/ormsgpack-1.12.2-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c1429217f8f4d7fcb053523bbbac6bed5e981af0b85ba616e6df7cce53c19657", size = 378262, upload-time = "2026-01-18T20:55:22.284Z" }, - { url = "https://files.pythonhosted.org/packages/5f/62/3698a9a0c487252b5c6a91926e5654e79e665708ea61f67a8bdeceb022bf/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f13034dc6c84a6280c6c33db7ac420253852ea233fc3ee27c8875f8dd651163", size = 203034, upload-time = "2026-01-18T20:55:53.324Z" }, - { url = "https://files.pythonhosted.org/packages/66/3a/f716f64edc4aec2744e817660b317e2f9bb8de372338a95a96198efa1ac1/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59f5da97000c12bc2d50e988bdc8576b21f6ab4e608489879d35b2c07a8ab51a", size = 210538, upload-time = "2026-01-18T20:55:20.097Z" }, - { url = "https://files.pythonhosted.org/packages/72/30/a436be9ce27d693d4e19fa94900028067133779f09fc45776db3f689c822/ormsgpack-1.12.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e4459c3f27066beadb2b81ea48a076a417aafffff7df1d3c11c519190ed44f2", size = 212401, upload-time = "2026-01-18T20:55:46.447Z" }, - { url = "https://files.pythonhosted.org/packages/10/c5/cde98300fd33fee84ca71de4751b19aeeca675f0cf3c0ec4b043f40f3b76/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a1c460655d7288407ffa09065e322a7231997c0d62ce914bf3a96ad2dc6dedd", size = 387080, upload-time = "2026-01-18T20:56:00.884Z" }, - { url = "https://files.pythonhosted.org/packages/6a/31/30bf445ef827546747c10889dd254b3d84f92b591300efe4979d792f4c41/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:458e4568be13d311ef7d8877275e7ccbe06c0e01b39baaac874caaa0f46d826c", size = 482346, upload-time = "2026-01-18T20:55:39.831Z" }, - { url = "https://files.pythonhosted.org/packages/2e/f5/e1745ddf4fa246c921b5ca253636c4c700ff768d78032f79171289159f6e/ormsgpack-1.12.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8cde5eaa6c6cbc8622db71e4a23de56828e3d876aeb6460ffbcb5b8aff91093b", size = 425178, upload-time = "2026-01-18T20:55:27.106Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a2/e6532ed7716aed03dede8df2d0d0d4150710c2122647d94b474147ccd891/ormsgpack-1.12.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc7a33be14c347893edbb1ceda89afbf14c467d593a5ee92c11de4f1666b4d4f", size = 117183, upload-time = "2026-01-18T20:55:55.52Z" }, { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, @@ -1324,8 +1325,7 @@ name = "pgvector" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/25/6c/6d8b4b03b958c02fa8687ec6063c49d952a189f8c91ebbe51e877dfab8f7/pgvector-0.4.2.tar.gz", hash = "sha256:322cac0c1dc5d41c9ecf782bd9991b7966685dee3a00bc873631391ed949513a", size = 31354, upload-time = "2025-12-05T01:07:17.87Z" } wheels = [ @@ -1379,17 +1379,6 @@ name = "psycopg-binary" version = "3.3.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/bf/70d8a60488f9955cbbcd538beae44d56bb2f1d19e673b72788f2d343ff55/psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a", size = 4609750, upload-time = "2026-05-01T23:24:20.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/b0/29e98ba210c9dbc75a6dc91e3f99b9e06ea901a62ca95804e02a1ae13e6b/psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a", size = 4676700, upload-time = "2026-05-01T23:25:21.727Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ab/3df087b3c12bf74e47c08204172b2fabb5a144679110d5c7ad12d9201323/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bdef84570ebbce1d42b4e7ea952d21c414c5f118ad02fee00c5625f35e134429", size = 5496319, upload-time = "2026-05-01T23:25:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/87/9a/f088207b4cd6772f9e0d8a91807e79fa2458d4eb9eb1ae406c68415f2bec/psycopg_binary-3.3.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa1cbc10768a796c96d3243656016bf4e337c81c71097270bb7b0ad6210d9765", size = 5171906, upload-time = "2026-05-01T23:25:34.004Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/4523a857f253871d75c22e1c2e79fd47e599e736bcba1bad58d83e24be02/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf7f73a4a792bc5db58a4b385d8a1467e8d468f7548702fb0ed1e9b7501b1c13", size = 6762621, upload-time = "2026-05-01T23:25:41.392Z" }, - { url = "https://files.pythonhosted.org/packages/7c/d1/925bf776503345bef428e6c45fb017d0139ddbe0e211814b585c4253dca8/psycopg_binary-3.3.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7b4d40c153fa352ab3cca530f3a0baedf7621b2ebcbd7f084009522c21788fc", size = 5006319, upload-time = "2026-05-01T23:25:51.419Z" }, - { url = "https://files.pythonhosted.org/packages/6f/aa/99727337206fbba357ca084bf4ea8b29dc986f61842a2685859af61416db/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f9b1c2533af01cd7648378599f82b0b8ae32f293296e6eec5753a625bc97ef28", size = 4535388, upload-time = "2026-05-01T23:25:57.957Z" }, - { url = "https://files.pythonhosted.org/packages/0b/a4/567ba2c37d19d8c2f63d836385dfd2495aa5897bbee6cfab104d9ee58624/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad3bc94054876155549fdaedf4a46d1ec69d39a5bcee377148afe498e84c4b8e", size = 4224544, upload-time = "2026-05-01T23:26:03.832Z" }, - { url = "https://files.pythonhosted.org/packages/b7/23/86457f5a82731685d7701de7bfaa5eb783dd1fecbf875321897d9d9ce33a/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb4eed2079c01a4850bf467deacfab56d356d4225040170af03dc9958321242d", size = 3956282, upload-time = "2026-05-01T23:26:09.983Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d8/249456df16d47de082abd9b73bce8ccdeb0293eb12e590f9150c7cbdb788/psycopg_binary-3.3.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f80e3f2b5331dbbf0901bcb658056c03eeb2c1ef31d774afb0d61598b242e744", size = 4261736, upload-time = "2026-05-01T23:26:16.798Z" }, - { url = "https://files.pythonhosted.org/packages/15/6b/c4abe228acafd8a385c1fb615d4f1e3c9b8ad7a4e4f0e84118ba3ffeed9c/psycopg_binary-3.3.4-cp310-cp310-win_amd64.whl", hash = "sha256:574ea21a9651958f1535c5a1c649c7409e9168bcbffa29a3f2f961f58b322949", size = 3570620, upload-time = "2026-05-01T23:26:22.655Z" }, { url = "https://files.pythonhosted.org/packages/b6/82/df3312c0ca083d5b43b352f27d4dd8b1e614bd334473074715d9e0000da4/psycopg_binary-3.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:612a627d733f695b1de1f9b4bd511c15f999a5d8b915d444bbd7dd71cf3370da", size = 4609813, upload-time = "2026-05-01T23:26:30.612Z" }, { url = "https://files.pythonhosted.org/packages/1f/b5/d74d542458d3e8ac0571d8a88f57ca369999b9a82f4fa528052d0d7d3e4c/psycopg_binary-3.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:13a7f380824c35896dcac7fe0f61440f7ca49d6dc73f3c13a9a4471e6a3b302e", size = 4676799, upload-time = "2026-05-01T23:26:38.475Z" }, { url = "https://files.pythonhosted.org/packages/09/67/06bab9c60671999f4c6ceff1b334f3ac1f9fc5789eb467c714623ea21de9/psycopg_binary-3.3.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:276904e3452d6a23d474ef9a21eee19f20eed3d53ddd2576af033827e0ba0992", size = 5497050, upload-time = "2026-05-01T23:26:47.061Z" }, @@ -1469,20 +1458,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, @@ -1603,9 +1578,6 @@ wheels = [ name = "pyjwt" version = "2.13.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] 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" }, @@ -1620,9 +1592,6 @@ crypto = [ name = "pypdf" version = "6.13.2" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/99/0a/48fe05c6bb3aa4bb4d2a4079a383d33c0dfec1edf613a642f07d8b8b5c2e/pypdf-6.13.2.tar.gz", hash = "sha256:5a96a17dbdfbf9c2ab24c0a13fa0aba182be22ba6f283098712c16fc242f509f", size = 6479250, upload-time = "2026-06-10T16:42:34.5Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cb/17/378943705992f74e451a06de3401ce68e3213763c81e44d0614559c45599/pypdf-6.13.2-py3-none-any.whl", hash = "sha256:6eeb9e57693f29d41bd01255d02660cbbb41fd7fc818a982677389a35e4f2083", size = 346555, upload-time = "2026-06-10T16:42:32.37Z" }, @@ -1634,12 +1603,10 @@ version = "9.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } wheels = [ @@ -1681,9 +1648,6 @@ name = "pywin32" version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, - { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, @@ -1707,15 +1671,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -1771,8 +1726,7 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -1786,23 +1740,6 @@ version = "2026.7.10" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/69/62bb7d63f26698949c905cb7ebe29c7b0659e2a7f2a50c35cc29640b0852/regex-2026.7.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:799a369bdab91dcf0eb424ebd7aa9650897025ce22f729248d8f2c72002c4daa", size = 494652, upload-time = "2026-07-10T19:46:28.394Z" }, - { url = "https://files.pythonhosted.org/packages/3a/f2/eed2ce38cc38def9c366d060ec739ff5f235a33647ceb73ae6be37306d39/regex-2026.7.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f0192e5f1cfc70e3cb35347135dd02e7497b3e7d83e378aa226d8b3e53a93f19", size = 295920, upload-time = "2026-07-10T19:46:30.342Z" }, - { url = "https://files.pythonhosted.org/packages/36/57/4d724eeb1c440d71ccd6400d33b62b911bb62ca05385fe1961556e628319/regex-2026.7.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:221f2771cb780186b94bbf125a151bbeb242fa1a971da6ad59d7b0370f19de9a", size = 290696, upload-time = "2026-07-10T19:46:31.953Z" }, - { url = "https://files.pythonhosted.org/packages/c7/9d/76c6779e424c64740d2a564b7ecb389a62c77b6294127d34f52008c91ea7/regex-2026.7.10-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2fb1f7a2deb4ca3ddebbae6b93905d21480a3b4e11de28d79d9fb0d316fcf8", size = 784833, upload-time = "2026-07-10T19:46:33.145Z" }, - { url = "https://files.pythonhosted.org/packages/54/ec/f777841d88f0c9d46699daf16f86a8086e077e506603be212de2c4584f85/regex-2026.7.10-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f98ef73a13791a387d5c841416ad7f52040ae5caf10bcf46fa12bd2b3d63745", size = 852182, upload-time = "2026-07-10T19:46:34.368Z" }, - { url = "https://files.pythonhosted.org/packages/d1/de/5ba208a0826117851f6c12af9ae7fae5838ccfde69b999b26c5fdc1cedc3/regex-2026.7.10-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9a094ed44a22f9da497453137c3118b531fd783866ab524b0b0fc146e7395e1d", size = 899571, upload-time = "2026-07-10T19:46:35.564Z" }, - { url = "https://files.pythonhosted.org/packages/7f/46/602b7b81d26a53113d3cec6dd845fd664d7b854b0ea45245bfdd6b9dc9ef/regex-2026.7.10-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53bbbd6c610489700f7110db1d85f3623924c3f7c760f987eca033867360788a", size = 794164, upload-time = "2026-07-10T19:46:36.972Z" }, - { url = "https://files.pythonhosted.org/packages/53/cc/c21ebc520c3e17d93e495eb2de2b1c5ae5f6780ccdb298b2d8ce1e940820/regex-2026.7.10-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:87b776cf2890e356e4ab104b9df846e169da3eb5b0f110975547091f4e51854e", size = 786304, upload-time = "2026-07-10T19:46:38.238Z" }, - { url = "https://files.pythonhosted.org/packages/65/d8/69ec8c062bbba8d4d153f9eb70ab43564f591035b81fc4ea7eb059fdf71c/regex-2026.7.10-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab39d2c967aae3b48a412bff9cdbe7cd7559cd1e277599aceaeada7bc82b7200", size = 769958, upload-time = "2026-07-10T19:46:39.541Z" }, - { url = "https://files.pythonhosted.org/packages/af/82/c56db326ac5f852865bd78d49a275757cc367e8114b13bc1015ed2491db1/regex-2026.7.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b56416091bfd7a429f958f69aaf6823c517be9a49cb5bf1daa3767ce8bf8095e", size = 775056, upload-time = "2026-07-10T19:46:40.808Z" }, - { url = "https://files.pythonhosted.org/packages/af/2a/1ba62462f679eb598d7325ff20798a264ddb9aa34a3f5d2ac388d54c6e4b/regex-2026.7.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:617e8f10472e34a8477931f978ff3a88d46ae2ba0e41927e580b933361f60948", size = 848857, upload-time = "2026-07-10T19:46:42.02Z" }, - { url = "https://files.pythonhosted.org/packages/84/5b/37bf2e2fe810540ca90bbfe457a2f61088721c95d442eee8bb1257165ad7/regex-2026.7.10-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:31fa17378b29519bfd0a1b8ba4e9c10cf0baf1cf4099b39b0689429e7dc2c795", size = 757747, upload-time = "2026-07-10T19:46:43.282Z" }, - { url = "https://files.pythonhosted.org/packages/9a/c5/131dd41f73f766af6b08492073b5f28bc4f907b5571230c9f9e895e88302/regex-2026.7.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5c363de7c0339d39341b6181839ed32509820b85ef506deafcf2e7e43baadab4", size = 837183, upload-time = "2026-07-10T19:46:44.91Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ee/00b9332c3c5d7460639f2c10fdc7197a64de6eaafff69478ec3332815335/regex-2026.7.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed7c886a2fcbf14493ceaf9579394b33521730c161ebb8dad7db9c3e9fcab1a8", size = 782151, upload-time = "2026-07-10T19:46:46.385Z" }, - { url = "https://files.pythonhosted.org/packages/e5/a7/31ce26ec6465c12e7136ea9efcc716f5c55cac616f7958c33f0bf171781e/regex-2026.7.10-cp310-cp310-win32.whl", hash = "sha256:b04583e8867136ae66353fa274f45121ab3ec3166dc45aaff3655a5db90d9f0e", size = 266770, upload-time = "2026-07-10T19:46:47.608Z" }, - { url = "https://files.pythonhosted.org/packages/71/00/22a554bb83203eef88a40ec2fe7cf9a6e5df8765d57f7a96ebe1af5d60c3/regex-2026.7.10-cp310-cp310-win_amd64.whl", hash = "sha256:e21e888a6b471b2bb1cdd4247e8d86632672232f29be583e7eafaa5f4634d34c", size = 277941, upload-time = "2026-07-10T19:46:48.915Z" }, - { url = "https://files.pythonhosted.org/packages/76/46/596a7084918ddf18cea6fb0cc047af1f00cec8790962e8f2edee9b6ec749/regex-2026.7.10-cp310-cp310-win_arm64.whl", hash = "sha256:081acf191b4d614d573a56cab69f948b6864daa5e3cc69f209ee92e26e454c2f", size = 276923, upload-time = "2026-07-10T19:46:50.829Z" }, { url = "https://files.pythonhosted.org/packages/3d/16/bfd13770be1acd1c05506b93fc6be15c759d6417595d1ba334d355efbf26/regex-2026.7.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341", size = 494639, upload-time = "2026-07-10T19:46:52.207Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/0086215709f0f705661f13ba81516287538886ef0d589c545c12b0484669/regex-2026.7.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1", size = 295920, upload-time = "2026-07-10T19:46:53.63Z" }, { url = "https://files.pythonhosted.org/packages/8e/9e/8e07d0eea46d2cf36bf4d3794634bb0a820f016d31bc349dfef008d96b02/regex-2026.7.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a", size = 290673, upload-time = "2026-07-10T19:46:54.863Z" }, @@ -1928,143 +1865,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] -[[package]] -name = "rpds-py" -version = "0.30.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, - { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, - { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, - { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, - { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, - { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, - { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, - { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, - { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, - { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, - { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, - { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, - { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, - { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, - { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, - { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, - { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, - { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, - { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, - { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, - { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, - { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, - { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, - { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, - { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, - { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, - { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, - { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, - { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, - { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, - { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, - { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, - { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, - { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, - { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, - { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, - { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, - { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, - { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, - { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, - { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, - { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, - { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, - { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, - { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, - { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, - { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, - { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, - { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, - { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, - { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, - { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, - { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, - { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, - { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, - { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, - { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, - { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, - { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, - { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, - { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, - { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, - { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, - { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, - { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, - { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, - { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, -] - [[package]] name = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", -] sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, @@ -2239,6 +2043,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/44/311bac6b6ef81e4dfd0287d04900108b1f5c00c9761dd3c0a2b7b9d0f86b/sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd", size = 10544216, upload-time = "2026-10-07T17:33:59.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/cd/264493ea522b887ac71949d442ef6a49ca04504e1090b427e878a71d5bb2/sqlalchemy-2.1.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6d147c31e189541ae7cd990482c4f960f9e8abce186551225fa355856dbf1a5", size = 2463376, upload-time = "2026-10-07T18:17:21.503Z" }, + { url = "https://files.pythonhosted.org/packages/59/16/1dbc3674709e945d113cfe0f652431cfeda0aa5999c0737444e7e4a416f8/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55072780d1aae84dea443ce27edeb745f6cc4d19ad89416abbb6b49712080e7c", size = 4628615, upload-time = "2026-10-07T18:37:37.947Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/0640dfb48fde362b83eaa122691457cb9a13f51d50cd6064ddcfba667c71/sqlalchemy-2.1.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:343a0493a81278bfe30be1ec81214a55f2f44aaa4662d230be359ab2aa18cc2a", size = 4650872, upload-time = "2026-10-07T18:24:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e4/5aec21a9e6ffadc919854fef1cd92b6f699ee204811e78ae1b1f9733da7e/sqlalchemy-2.1.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8080022e101afb17565dc5a358a165ff4a20cd97b20b4db49ebed66315b3c733", size = 4380816, upload-time = "2026-10-07T18:59:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/7aaf2b01d4d9c7168a55e0c318ab494ab436b434ebdfd4977fee5fabddf9/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:948dff080b5ac00c8e63bf9e59fa70e386cca1476f55c672a72b6ec12e5cdb05", size = 4566541, upload-time = "2026-10-07T18:37:40.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/61/3e4df04dd09d1db05ea31a2d7dc015aed26e33fefaca610eecbfe9b8d26b/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:12642e105b4e0cb2ca8428037368c1cbcded7b9d0344174607174d82b700e1eb", size = 4377842, upload-time = "2026-10-07T18:59:42.612Z" }, + { url = "https://files.pythonhosted.org/packages/53/4f/c983249adefed608b0cdc13bffe43a47a032316548e81bfdb4b6282a5b56/sqlalchemy-2.1.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976bd3fecfcfa58d69eab67e76325f564ed775aa0c0accf138ae17324b461431", size = 4621091, upload-time = "2026-10-07T18:24:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/257469b63c8cfad892b796c54a1392b3dfef93ee9273af5a8f49065d77ce/sqlalchemy-2.1.4-cp311-cp311-win32.whl", hash = "sha256:e2ace725a430e5b303fc3c422196966328ce77fb4fd053ad85572b46ed5fb71a", size = 2379577, upload-time = "2026-10-07T18:24:56.929Z" }, + { url = "https://files.pythonhosted.org/packages/3d/53/eae7fc135ac36ebc6385e87975ed5672d6311f0350f0358f38906a877f2c/sqlalchemy-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:3c998d70e60fc95e93e5971395818c50f8a34396a6352075256fefac6b5cf81b", size = 2430336, upload-time = "2026-10-07T18:24:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/81/fb/73b7ad29f65d9a114a3b42fe10ddf654bc360855dd29455381d4c7c34f97/sqlalchemy-2.1.4-cp311-cp311-win_arm64.whl", hash = "sha256:d045e63095828d2f1fd84d499936e6791522c15c390373fc755f118e4040393a", size = 2394934, upload-time = "2026-10-07T18:22:34.762Z" }, + { url = "https://files.pythonhosted.org/packages/49/5e/cb5b078e007340661b010fa8bd31ce27468f88e09b35266544df4e0c52ca/sqlalchemy-2.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f953be9ba26039a24a5205c65d33518b608ce6f4f0f4e9b9c14eaf42a10dfc52", size = 2466469, upload-time = "2026-10-07T18:17:24.049Z" }, + { url = "https://files.pythonhosted.org/packages/b1/98/44e2fdc5bc053dae559bf4f4eb7967ceecbad162299ecfc8de2edc3fcbe7/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac64fce94c5b389062d2e3806db5dc780447591e0dfd5ead218c884f0703f2e", size = 4668213, upload-time = "2026-10-07T18:37:42.294Z" }, + { url = "https://files.pythonhosted.org/packages/08/25/ed2262f964687b06f10c2c98b2dc9c9ed211f7cc11702879969a9ac217e4/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e5045fb6aadbb0f978ab9b9d8822f7b7a97d2281814e7d13d791155664eace3", size = 4720857, upload-time = "2026-10-07T18:24:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d4/fab64c61d5d22ddbb077afd1e6b29b498bdacdf6406a03f53566e7e01686/sqlalchemy-2.1.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3a026436c51f296aa1d01243909a3b76490950e927824b10899a083cc26e7c3", size = 4369351, upload-time = "2026-10-07T18:59:45.483Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e4/33413f0fafbcf3b332320aac2c1e40f3b4f17e56359a9474cb10de4bee8b/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71040390ef01c85e9d26e5c83cb0c5942dcc8725c49186430af160ce2f54234d", size = 4591496, upload-time = "2026-10-07T18:37:44.433Z" }, + { url = "https://files.pythonhosted.org/packages/bb/65/19821440cbd5c93da053d627b3e402eff11ff252bfae37700645b3c155a4/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:07c60abaffb980b7382f2c75be8a5279c2b5df2626a0f5d751dd942799bf3b5c", size = 4362379, upload-time = "2026-10-07T18:59:48.278Z" }, + { url = "https://files.pythonhosted.org/packages/01/e3/168a0f93efd6ec40f59645a7e45ab08918e0bc8ecf07656e4ca09acdcc30/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a577e2127e52b0fe2bc54c73abb375a20ffe6f59fbc5568ccafc233f5bfcf8ef", size = 4667997, upload-time = "2026-10-07T18:24:48.72Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/0a852ef65864acd8d577d7aa6f67146167382bd6faee7a7586b9e6e28275/sqlalchemy-2.1.4-cp312-cp312-win32.whl", hash = "sha256:6c79e0c824d51c586757ecd342160bbdede9010df04bb71b9bbfffd5c7b6ee29", size = 2376664, upload-time = "2026-10-07T18:25:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/a5934263bb1d712f743289ca224ab3b87e3570ac157802291e37ab85d365/sqlalchemy-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:dffa69d2f3ba1933c1c1882dbef8fb3231b33eb19263e8b8c5cea24995071f06", size = 2429357, upload-time = "2026-10-07T18:25:02.565Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fa/a2323d81384ff214aa189057b7455b63623e66f28208b982e86c3cb042f5/sqlalchemy-2.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:e30524ae24e31d83e1b5f734862882c442f4158e3566f2c5f5e9bd3c659bb517", size = 2388756, upload-time = "2026-10-07T18:22:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e4/23174288ed2c03d6dbd5dfacd69e28303ee95f49642a8ed0544932999fb6/sqlalchemy-2.1.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:70006e9e6157200b795beeee04bd5cb15bccb40a14de595eb9f5dcf5945ed244", size = 2460507, upload-time = "2026-10-07T18:04:40.044Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ac/254fadc98bfd600445b976e81c6d777b08a728a415c3b77a8c8d35b89a83/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3341ddc430733cd961bc064889f42712a0b4056733a21c83176842aad67d12a6", size = 4594505, upload-time = "2026-10-07T18:16:58.768Z" }, + { url = "https://files.pythonhosted.org/packages/83/6f/ac7beddc57c9c87bd77bc1c158fcbcdc20822f1873bf33ea3480d04e865f/sqlalchemy-2.1.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98f7a4bfeaed3722804f737ae2bd4077b35e57d6f4531fe612bac8160cda5acd", size = 4647364, upload-time = "2026-10-07T18:34:51.721Z" }, + { url = "https://files.pythonhosted.org/packages/0a/82/fc3891f261c4738a8b90cfdd805fe292d1af3b77f680a63b7349304c74e5/sqlalchemy-2.1.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ec5d079935f67febe0ab8a3a203ad591b99508adc34ae0027f696dcb20373537", size = 4311619, upload-time = "2026-10-07T18:38:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1a/160c1320ab20e764a29721dc3fe7c31af34e291c652dca875d1ca6022b9a/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3d675b0856b6703b29d023517a4c19fecfbb55214ff5c72cd813527e40aed9b4", size = 4518031, upload-time = "2026-10-07T18:17:05.615Z" }, + { url = "https://files.pythonhosted.org/packages/30/2c/15a204333896e5dc63cb089ea20ca3ebc3c892bedf9fa00cc1a65e20d7b5/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a0bb9ee6a38cb36240dc88da11888348f61506047be54de3f09496c3b0ead6f5", size = 4312818, upload-time = "2026-10-07T18:38:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/a6/55/5e78d288f198598f278b4b7baef42f18e039b14b1e1045e9df3cf571300d/sqlalchemy-2.1.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61a2c48771cf314b6613d327c795902bbc0eb6d6169deb23b35004ba6ad6cc0d", size = 4608584, upload-time = "2026-10-07T18:34:53.69Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f6/e83b93ecc6e6528623fd7aa2af27ff0660d22354b78fe6ccad03f9ecbd9f/sqlalchemy-2.1.4-cp313-cp313-win32.whl", hash = "sha256:3fd608a06bafa768ad5711df4e17eb058bdc490e9df7d39b12a90947471e8712", size = 2373880, upload-time = "2026-10-07T18:22:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/8f/46/afb02975023db6aa4b8608177c2fae17d0b435d9cbfcb5df4fa6e65a8078/sqlalchemy-2.1.4-cp313-cp313-win_amd64.whl", hash = "sha256:b756d74527c56a7e4cfae297f7930c1d75bdf4b23f214c8c13779746d28060cb", size = 2424430, upload-time = "2026-10-07T18:22:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/21/e5/76dc82d59186b98b27589b33b01175c0d49512679276170271d9384418e2/sqlalchemy-2.1.4-cp313-cp313-win_arm64.whl", hash = "sha256:a64d54015233f824f171009977bfbb6b08bd0347b700cf17cb047ffb94c4148f", size = 2385057, upload-time = "2026-10-07T18:11:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/43/b0/6675a01f4e6215e0a809d28a800953294ab31370fe8c4bb3eb9e28c0b5a6/sqlalchemy-2.1.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a2f6164c0527cd8fc4cea79a5c9d8369ffee417b8ba444a42342f36b91deb75", size = 2463645, upload-time = "2026-10-07T18:04:41.615Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/4630a4009ea08a0769d5ff6517c7fc978f6a63eba32e08c44b98c284d7e4/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6929a11ad26a91a4efd891c1252b373c2e88f056910b83ec6030ed3f2cbcb734", size = 4587911, upload-time = "2026-10-07T18:17:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/0e/02/953686f44448b92cc628245687a242799b6eb11ef30ad2bc7adacd51986d/sqlalchemy-2.1.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14528d37d7d46a92f2a483f188f7fecd86cdd789254a0412b960c9fc5e9efd6d", size = 4617539, upload-time = "2026-10-07T18:34:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/13/23/a44288ab4fa12e51c9d390e7d798d70a45669ddcbddc9dd9b5948eb1aa3f/sqlalchemy-2.1.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2cb669c6bd1f19caf51db6e3c4fdd4cbb76f9db3ef81c3aeb5e288d9bae101b", size = 4310883, upload-time = "2026-10-07T18:38:50.265Z" }, + { url = "https://files.pythonhosted.org/packages/a3/39/1c441ac015767f619a9e6cc306905bb042f94b84f2a1e930e989e9c6e209/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:63dc25b21fd9a41dc09b7aada4b3b0d97cf4b6414f74bced6ac45326bc799ac9", size = 4516511, upload-time = "2026-10-07T18:17:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b9/f54ea5ccb27d9a712d90d1617050bee761df25dc1fb5e0b7d2aa867deb51/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:308f96d24e773d64609a2a0d1161a068f9f6e9165523bc4e07aa9c45f0c4213f", size = 4312314, upload-time = "2026-10-07T18:38:53.249Z" }, + { url = "https://files.pythonhosted.org/packages/df/9a/c1e39287ee988e4c2e25c619959b8fb15b297734be040653fe85b57517ee/sqlalchemy-2.1.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93b9416b9011a3b7689a933e04ac9f61d15686b6cb1948ebc1f41467153116c3", size = 4577833, upload-time = "2026-10-07T18:34:57.829Z" }, + { url = "https://files.pythonhosted.org/packages/41/78/5f1ae1911d2b20ccdb39ee522118533a4b5262b6e5e06bbcbb1ebd1f4617/sqlalchemy-2.1.4-cp314-cp314-win32.whl", hash = "sha256:89db94855287fdac98d74595cf13ea59fbffa608d6400ff972b0fd4c036d873f", size = 2380724, upload-time = "2026-10-07T18:22:25.374Z" }, + { url = "https://files.pythonhosted.org/packages/ca/93/4dfa4ce15d082011fb94e06e7c6b4c2957a3f0ddeb8fe9b89d007bc058d7/sqlalchemy-2.1.4-cp314-cp314-win_amd64.whl", hash = "sha256:080f8d853aac5bb5620f0ae6f46527397cf18dce0ec2b478b478469ef3cae2c4", size = 2431689, upload-time = "2026-10-07T18:22:27.144Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c4/6f6c29eaf459c4c2d9b7d24e300bab32043f8f8a936df863f3b886b5564a/sqlalchemy-2.1.4-cp314-cp314-win_arm64.whl", hash = "sha256:64d41be1dd88f184de1931f0173f4827122a1b49fd1150656641200c0bdf640c", size = 2396106, upload-time = "2026-10-07T18:11:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/48f851411665e394f60c669d1f9494d660f5f1fe46e275f9615cfc812a98/sqlalchemy-2.1.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84272f329c15081a1e09b4a7261118b4e8a547f43e00fca98e55bbdf19eff3be", size = 2503038, upload-time = "2026-10-07T18:19:41.094Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/bf83068bda4051d7fd719c14cefc15d8466ef1e3656b9f4401b0509b11e0/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3f58bd26fc010ea28976d401845e4e6ce02e1b7c0288b3ea9c9a3c396f0bcc", size = 4919623, upload-time = "2026-10-07T18:16:45.399Z" }, + { url = "https://files.pythonhosted.org/packages/56/de/57eb70d56b70d22a9360d658b195834ecfdeff7a7bc5c2e3a7fa7a8f7823/sqlalchemy-2.1.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82d728075d42bd457d09655cf22e99d772a648c6f67e86743a4f05b7d063ca18", size = 4830238, upload-time = "2026-10-07T18:37:04.468Z" }, + { url = "https://files.pythonhosted.org/packages/70/3d/c410e9e79a53fff4c04444da609fed6404868d250f11fe8bc53d827bfb0e/sqlalchemy-2.1.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0970394ec5d9e397aafc5bc5fa2b7f8b58cb191f2703006b19a96ef4bf00b8d9", size = 4508380, upload-time = "2026-10-07T18:38:44.277Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c3/01b93821ba35b5b162e79c613279d960a120767694f656da1c1374dd3ed3/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6005f2f5fcd67fdd721446128e6a2a1d18f77387a604fbd26b0006a086b33096", size = 4775311, upload-time = "2026-10-07T18:16:47.724Z" }, + { url = "https://files.pythonhosted.org/packages/c7/88/0b40754e4d851d33548792062c23467a3d8dc07f2eff90cb19e4c404fb4c/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0e01a3e199ae219381c4889993c5584b1b905fffe6830f639adb6770036a8913", size = 4508558, upload-time = "2026-10-07T18:38:47.857Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2f/3916954eca5596d9e93fccd2ec0e45fd8c65981debac0ec4617639ded6ba/sqlalchemy-2.1.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22129e7d00ac66b291840c4dc83a9c497456ab5bffa682dcbfdc2356f9e49e5a", size = 4767114, upload-time = "2026-10-07T18:37:06.792Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d6/6a29716aec6ae17cd77e27b5e0dedc68cf9068594f2b601806c1d146427a/sqlalchemy-2.1.4-cp314-cp314t-win32.whl", hash = "sha256:bc33d3e59d4e84b8866cc9ba13732585e37212dbe3542cb09f232682b36f47a5", size = 2439584, upload-time = "2026-10-07T18:22:44.434Z" }, + { url = "https://files.pythonhosted.org/packages/34/79/2f0b33647d2d26f098269096c1864c0b4e81095354cdedb95192647f47cd/sqlalchemy-2.1.4-cp314-cp314t-win_amd64.whl", hash = "sha256:346d144e8912ae087b10d3c2081657cb634728600693eee6dbb71d7eb4768101", size = 2507217, upload-time = "2026-10-07T18:22:46.176Z" }, + { url = "https://files.pythonhosted.org/packages/93/e5/869c1ac0a21e17e4617b6a7828b50320bedb7074b6d67aec59299be5cdba/sqlalchemy-2.1.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3e5de57c71b3460e2ca6137e82cd3cb8c9f711f301f50d5c77156fdb9c822999", size = 2425329, upload-time = "2026-10-07T18:12:20.595Z" }, + { url = "https://files.pythonhosted.org/packages/2b/8e/a082a165b473dae45d2f2f79be15f5c405ac579830c64253efbf04695177/sqlalchemy-2.1.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:418786f05387ddb66ee683a1d016c5a8d9bf7be921e6ee8f285c7b6ac961a731", size = 2463572, upload-time = "2026-10-07T18:11:12.053Z" }, + { url = "https://files.pythonhosted.org/packages/d1/35/74db254005ecb384533973b157ba1fc3fe5bc41a5bc6e0500ab8369c49e6/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:283914efed30e4d44301e36ac90ad048570538b8a70f072fe01578d9b205d09c", size = 4591740, upload-time = "2026-10-07T18:01:00.314Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/5cadd72b0c26b6ee7c1e6950cb9f0cfc383246a842314a1b2a87f455db25/sqlalchemy-2.1.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d2eacdbeb990b80235763860923c60a8393745b66f7149a734980c65896da72", size = 4627040, upload-time = "2026-10-07T18:09:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/aed93cc373f61b57625e1f9f84bbf12358e32e935e64fa098f3a446e1203/sqlalchemy-2.1.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e43fca5fdd5f34a3f8c54107a3648d3139de8bbf596a189f3f0de94bd84949bb", size = 4333103, upload-time = "2026-10-07T18:33:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/ecc6bbd365671cdc512a59d42afa7c34b2833a8d841754918ae3f62d36dd/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2e1b5343d315b10a4a71da481729f66f830a561595e02b61e8a5a65d658325ac", size = 4519962, upload-time = "2026-10-07T18:01:02.268Z" }, + { url = "https://files.pythonhosted.org/packages/58/58/9f8f6157c2252aefe73f4a0b3859413bb720d14321aa7f367c691949aaf8/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:42c37c06adcecf444e8c981f7e9237a41bdd445c83da0df9e08b4ad958becbbc", size = 4331935, upload-time = "2026-10-07T18:33:50.334Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/a4ae4b95d17607004f01e9a085fb221087c557bbad77a3d87d5d0a5fd8bc/sqlalchemy-2.1.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:bab7f51d38766d6a64da2b41976f1b3f9cc2ff37d3f2f63bdbac876199f3a48e", size = 4589275, upload-time = "2026-10-07T18:09:26.872Z" }, + { url = "https://files.pythonhosted.org/packages/65/27/56f69293a01279ac0e6077b8c358eb0f1c2afc6aa17428414a86c8871042/sqlalchemy-2.1.4-cp315-cp315-win32.whl", hash = "sha256:1541ba5bf0f232cd61f9ef3df78c93977c72ba6031506a0e6d057b2a3ddb76e9", size = 2380494, upload-time = "2026-10-07T18:04:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7c/ff7e29f95996ed49b950afd531b89e7c8d15addb41735643d07090550090/sqlalchemy-2.1.4-cp315-cp315-win_amd64.whl", hash = "sha256:596a95611c217cb19c21f02f43c637cb507cab71dcf0467c5c7d98fcdd703007", size = 2431760, upload-time = "2026-10-07T18:04:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/4eaa4978760cd632093ea272e7c4f88223619202f5481f897e67d4377409/sqlalchemy-2.1.4-cp315-cp315-win_arm64.whl", hash = "sha256:0d1ca95e42ce3c18818f170b741d30a33b292c6f6b9a202ffd717e28fc99b8c7", size = 2395807, upload-time = "2026-10-07T18:30:54.962Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/b806fbfc61ade37c4f3aecec0874c345fb297b56a3743116dcefa3e4700d/sqlalchemy-2.1.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0f672ed6972164fec94a8f0b21dcf8545080d0727866335fb8adf9f4764ce6ec", size = 2499195, upload-time = "2026-10-07T18:19:42.835Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/4f9fba8340222f09287e936d7b76e6911a4e507c7d6373ada770e8f697d5/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72e3fa41d1fdab87d4e88bbdd69c9522e2795549fbe7b07bcf4ae9ec175f4b11", size = 4896914, upload-time = "2026-10-07T18:16:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/55/34/c4aeec7bee453badd8b0e02c2021a13bd70ef01038303d05326e99f595b6/sqlalchemy-2.1.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cb2cb98d056e63e353ed697750004e07c79b054d73059ba3184ca3bb07296bea", size = 4846593, upload-time = "2026-10-07T18:37:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/82/54/6dd8504364e5f5efd328e98fea963e5a2e978ff8dcba70d95231314f82a9/sqlalchemy-2.1.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d66fdcc5506e0f8bb8d3f4f95125220a7cd6c46e8b1762750f01e9639973dd8", size = 4494821, upload-time = "2026-10-07T18:38:51.166Z" }, + { url = "https://files.pythonhosted.org/packages/df/42/dc584c098bce29578fd0611cd6f36830e06b4dd2505d3020a0b592f4cf08/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:81f802c96dbf96e59c6982fa1b87da7868920fb0c27b9b81e560a62f57c2ccfb", size = 4752545, upload-time = "2026-10-07T18:16:55.711Z" }, + { url = "https://files.pythonhosted.org/packages/8c/41/69a70c1419bea97e80f65ce09f4f626df464752b276f4f3d69ff6fbf2325/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:acf8982c70471a68aa90d1aba08b48860c55b3357ec84ccb0f09368ead2ce099", size = 4494093, upload-time = "2026-10-07T18:38:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bd/d296c2223e8417b350db215d94dcd344bc0dfe9deb7d810a21f7d8cd0b14/sqlalchemy-2.1.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:778094c83e36c430756a7e1a1ac66fc3cffb2c6a1067958fe6b920abcec7bc5a", size = 4771973, upload-time = "2026-10-07T18:37:10.93Z" }, + { url = "https://files.pythonhosted.org/packages/13/4c/c3a10d9da10e4e60808ffd1825547b383c0d7ca9e56d15cdae47c04e752e/sqlalchemy-2.1.4-cp315-cp315t-win32.whl", hash = "sha256:963348422b22f760e9462e56bc32bf4d95d224cc5b8c79a3c6e3b786d3d2a2b2", size = 2436383, upload-time = "2026-10-07T18:22:48.162Z" }, + { url = "https://files.pythonhosted.org/packages/51/de/8045d4ad1fd3a66c3b9bb576f3734c86015e19ae2f1617af92eb63cf9e58/sqlalchemy-2.1.4-cp315-cp315t-win_amd64.whl", hash = "sha256:fba3500e170d25f581e053009edeb0b158116084d91d465de218718d336b67c3", size = 2502237, upload-time = "2026-10-07T18:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4b/245e2315d331cc15765a2373e068445fbd28eb63beb23ea862828808c0bf/sqlalchemy-2.1.4-cp315-cp315t-win_arm64.whl", hash = "sha256:0a9a464bc360856b7ea9bf8aa26aab92ca115dd08149cb0e004063d5db13584b", size = 2420917, upload-time = "2026-10-07T18:12:21.876Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/dbf11a262f6fbb41390cab2d8e47a30ec0961018b68201607b599dd489f5/sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7", size = 2054935, upload-time = "2026-10-07T18:01:16.403Z" }, +] + [[package]] name = "sse-starlette" version = "3.4.5" @@ -2270,6 +2156,7 @@ name = "supportagent" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "alembic" }, { name = "anthropic" }, { name = "boto3" }, { name = "boto3-stubs" }, @@ -2288,6 +2175,7 @@ dependencies = [ { name = "python-multipart" }, { name = "pyyaml" }, { name = "requests" }, + { name = "sqlalchemy" }, { name = "uvicorn" }, ] @@ -2299,6 +2187,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = ">=1.20.0" }, { name = "anthropic" }, { name = "boto3" }, { name = "boto3-stubs", specifier = "~=1.43.54" }, @@ -2319,6 +2208,7 @@ requires-dist = [ { name = "pyyaml" }, { name = "requests" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "sqlalchemy", specifier = ">=2.0.54" }, { name = "uvicorn" }, ] provides-extras = ["dev"] @@ -2342,13 +2232,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/e3/03c90dadcf5b3f82b83cee9adee60ef666b329c654f58c066af44eae0287/tiktoken-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:47b1df8d73390a24f94980c75158cdd5c56d256f16d55f30cb49c230caba9ba4", size = 1036627, upload-time = "2026-05-15T04:50:11.229Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/760463e5b2e8ad2bc229ae0a17ecb06727b6cbc094f08d8f65844315632e/tiktoken-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7d40c6c5aab171dcd6eb8455bc567bde404bb9def60cdb8c1299cc782b242bb9", size = 984699, upload-time = "2026-05-15T04:50:12.874Z" }, - { url = "https://files.pythonhosted.org/packages/de/8a/8895f342a6b6aabd1a358e672f6f077b3ae51d0c63ca605d142db3bcd8ab/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:9b842981fa91accdffd48ff6408a977b7a91c3fbda55d353c3c68114d5c9d69e", size = 1118690, upload-time = "2026-05-15T04:50:14.234Z" }, - { url = "https://files.pythonhosted.org/packages/51/e0/92557768fb0801f0d9dd9243cb9b6d342900b05e4b1006d4771f49ce233e/tiktoken-0.13.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed5a30027cb4d8c7ca8b273d4766f3db3cf58fad9e9f3b1a68a351ffb54873d5", size = 1138423, upload-time = "2026-05-15T04:50:15.668Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b9/a3d99feeedb032ffd09cd6652077f86bdee9a70dd0b990b2b272b445d4c3/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7ab10f4a21c2999846940113f6dbd72e0fa06a24119feddd74cc47e85818e06d", size = 1185077, upload-time = "2026-05-15T04:50:17.19Z" }, - { url = "https://files.pythonhosted.org/packages/cc/93/bab868277d475dc6d2aaacd34cdd239c282f4908dcc8702e0a3311a8e032/tiktoken-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a2937ad042d49d50eac6e1ba07c5661d4bd3942a5b1e0c0d08475c4df83676e1", size = 1241702, upload-time = "2026-05-15T04:50:18.772Z" }, - { url = "https://files.pythonhosted.org/packages/c3/16/27e9f7e0ed76e501cfefc9fb2112df4c7bf70ca96945b15ecb7615aac860/tiktoken-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:44733b99bfd72b590cd0936b1c01b3b4dd73122db2d544bc1ceeb18a7678c910", size = 876565, upload-time = "2026-05-15T04:50:20.268Z" }, { url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" }, { url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" }, { url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" }, @@ -2393,60 +2276,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, ] -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "tqdm" version = "4.68.2" @@ -2522,19 +2351,6 @@ version = "0.17.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e7/91/63938e0e7e7876658e5e40178e7c0735b53527886fe11797a11699c55edd/uuid_utils-0.17.0.tar.gz", hash = "sha256:abb5667a36119019b3fa320c4d10c21ebccfcc87c8a739e6a0056cee7f48dde2", size = 43220, upload-time = "2026-07-09T13:49:58.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/60/659104207938f2ac62508b9aa595fc0515ac7452dd515c8e1d47d0b91169/uuid_utils-0.17.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d2d9a63a9e6f2416ace8c109043a9280d6b34f34bb2e5421903e149403db40a6", size = 564038, upload-time = "2026-07-09T13:47:51.731Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e7/e0d048a268b4163058bdd2f07a45bbe13c29e3cc6b7b88f8f00b001617ce/uuid_utils-0.17.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b776c7fc8755c7de06dd5a22b47c40ae84f67d13277ebb233cc84933ba4dcbcd", size = 286680, upload-time = "2026-07-09T13:47:53.141Z" }, - { url = "https://files.pythonhosted.org/packages/84/83/e3606dc9b4224d0c9a6675d9347e7e0da7e67fa30e061bfdb686138844d0/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1edf2f8732e4ed95bd7b65f2658f4aa072efaaff321144f4e0d4bf6a22709263", size = 323533, upload-time = "2026-07-09T13:47:54.433Z" }, - { url = "https://files.pythonhosted.org/packages/22/f8/aec5c34fa80c9fef09a506a098015e728080076494b72b9e8e5cfc9669c4/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84ed3a2d5cd3ae6db87af20bfed3331116195ba4757ad7177fc8f12c1bbce2a9", size = 330691, upload-time = "2026-07-09T13:47:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/08/73/85776566863514f37b0a761648368e96b07d64981a9b6c391220aa2563a9/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bf4d9cd1e80e73922073b9b27c143bedeb109d65f94cd12712e2c87118f2b7d", size = 444094, upload-time = "2026-07-09T13:47:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/68/06/e0424b4268c0932e0ff8257303d70de4053f05958843268fac4cb0f79b57/uuid_utils-0.17.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52db0e471d3d2632d35445af352591f40a8f32959a412981d9f51e068bb9514b", size = 324548, upload-time = "2026-07-09T13:47:58.217Z" }, - { url = "https://files.pythonhosted.org/packages/db/d2/a0cb3a69ef6d9becc30a6a0594ddf6f798f6204953dfa85073cbec875b94/uuid_utils-0.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:344f7c755e280ea0ba6aeb08022190d867a80000b1715cacded54fc4b5633607", size = 350307, upload-time = "2026-07-09T13:47:59.418Z" }, - { url = "https://files.pythonhosted.org/packages/82/81/d82766af7db541e4a78b920bc1c4303d44995f841805d1498934088cd12c/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:589d9da7de8fa7f739bb970ac4632c9a268213117d634e1c4a58c1c1e821ca05", size = 500661, upload-time = "2026-07-09T13:48:00.726Z" }, - { url = "https://files.pythonhosted.org/packages/10/71/b261cd0d38497ed8c2cce0263c5607ec9cd2bbace0f73cb19a6fc2060b6e/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cee808b405e9095506f4e4e89924bec7ea77eac3129b6fe36eda04364b3b343b", size = 606577, upload-time = "2026-07-09T13:48:02.539Z" }, - { url = "https://files.pythonhosted.org/packages/3b/63/9e48512bb235e9533adbb25c30fd0c9cef09f6ecefe131ba392b98572b40/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:53ce348ef4c6e98c02c19c522af01334fe94476ce9af0db8c4482f9f142ae9c1", size = 567054, upload-time = "2026-07-09T13:48:03.833Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cc/d7bad8799a37ec33fc21b29fcb459d63d9f88aa09056d0c3e58903ba2fb0/uuid_utils-0.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9e753e81457241e2200c56a898e268e8fa25796271af0489c608f24d8e631eed", size = 529682, upload-time = "2026-07-09T13:48:05.097Z" }, - { url = "https://files.pythonhosted.org/packages/4a/3b/59b1e07ada8aadd3c046c97fe9814d85e770abb7e8cf68d5d86538bf62e9/uuid_utils-0.17.0-cp310-cp310-win32.whl", hash = "sha256:c589f5023d471ce75dd2cce61acb25ed6347e562041588a1a366808f22d7176c", size = 170595, upload-time = "2026-07-09T13:48:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5c/23a2d0253ada2ee8c497d541d4ef0dd5576c3d2454ec2f9d0b8a06af9304/uuid_utils-0.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:981cc10163988defea96e8d6c507df151eab8f483e7df9ae543d5a41a4be073b", size = 177225, upload-time = "2026-07-09T13:48:07.561Z" }, { url = "https://files.pythonhosted.org/packages/d7/b2/8f03b61f0aa4afc687855c4f00db35f4d3e58c480cd885abc46f6e41308f/uuid_utils-0.17.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:f9b093cb3b6c9d6233ef45a05cab064d2aa0a8cb3c5777084c9e20fcb77c2371", size = 563901, upload-time = "2026-07-09T13:48:08.961Z" }, { url = "https://files.pythonhosted.org/packages/e3/cb/88b909ffb9ac11f88d2e6ceabc592ccc660b5830b06dbcbd290ab8981f1f/uuid_utils-0.17.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0bc4c431ccd59c764080ceb43b126043325fe17861b87759d026a0cdd8423bb2", size = 286383, upload-time = "2026-07-09T13:48:10.2Z" }, { url = "https://files.pythonhosted.org/packages/a3/b8/bc5b64e9898867227c535cd0366c571c580a736748e81329437c1773e442/uuid_utils-0.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00d182e31034250690f417b9068b78eab423c10d76766664e82d9860c340479", size = 323244, upload-time = "2026-07-09T13:48:11.477Z" }, @@ -2624,7 +2440,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } wheels = [ @@ -2637,17 +2452,6 @@ 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/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, { 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" }, @@ -2681,12 +2485,6 @@ wheels = [ { 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/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, { 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" }, ] @@ -2696,17 +2494,6 @@ version = "2.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/8b/59781d0fe7b0adfbea37f600857de4be68921e454aeecf1a11bda35cdccc/wrapt-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:055e6fcfaa28e58c6a8c247d48b92be9d56f818b7068aa4f22b15b3343a09931", size = 80556, upload-time = "2026-06-20T23:47:28.473Z" }, - { url = "https://files.pythonhosted.org/packages/94/dc/66c61aca927230c9cf97a3cb005c803971a1076ff9f7d61085d035c20085/wrapt-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8374eb6b1a58809211e84ff835a182bb17ab2807a5bfef23204c8cff38178a00", size = 81648, upload-time = "2026-06-20T23:47:30.504Z" }, - { url = "https://files.pythonhosted.org/packages/23/1b/545eee1c18f3af4cf140bb5822b6ef81ebe569df0a63ac109973103a30a5/wrapt-2.2.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:656593bb3f5529f03d27af4136c4d7b11990e470bcbc6fefa5ef218695bece55", size = 152956, upload-time = "2026-06-20T23:47:31.867Z" }, - { url = "https://files.pythonhosted.org/packages/44/a7/6f42a3d03e44dc612a5dcff324e7366075a7857f0be2d49a8cb8a68279b8/wrapt-2.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb00cb7bb22099e2f64b7340fb96113639aa7260c0972af3797ace2297b936c", size = 154771, upload-time = "2026-06-20T23:47:33.352Z" }, - { url = "https://files.pythonhosted.org/packages/bf/55/4d76175aaa97523c38f1d28f79d18ab41a1b116814158a818bc0eba00571/wrapt-2.2.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7f10ee0bd53673bfd52b67cbce83336fe6cad90d2377b03baf66491d2bbfb91", size = 149460, upload-time = "2026-06-20T23:47:34.712Z" }, - { url = "https://files.pythonhosted.org/packages/84/9b/12e23264d8f4735e8483262f95c5a6b03c3665fd2a84bdf99a45b6a2f4ec/wrapt-2.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4402f57c5f0d0579599858ffbdd9bf4e3f0972f51096f2bd6cc7dab6b76ee49e", size = 153648, upload-time = "2026-06-20T23:47:36.092Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a3/bcd5ec37289dcd85ecd4d15395a6a6063d60bc45ff94a9d77814e1e54d64/wrapt-2.2.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3a4eb7964ff4643d333c84f880bcf554652b2a1050aebc54ae696327f61acfaf", size = 148502, upload-time = "2026-06-20T23:47:37.623Z" }, - { url = "https://files.pythonhosted.org/packages/f2/be/716d708f607fa70f8a6eb47dff8ee945d5278dfc89ffeeff33039d052e63/wrapt-2.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e542b7c5af91e2123a8aabf19894319d5ec4268d2a9ffd2f239386133fc47746", size = 152238, upload-time = "2026-06-20T23:47:39.118Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c0/1a48e7e54501274f5d906f18372221b13183b0afbb5b8bb4c7ca0392c0b4/wrapt-2.2.2-cp310-cp310-win32.whl", hash = "sha256:6e7e45b43d3c774d244fe7264378f5a3f0f383bc55a54a9866434e524540110f", size = 77278, upload-time = "2026-06-20T23:47:40.476Z" }, - { url = "https://files.pythonhosted.org/packages/b0/82/9cd69a1af288fbdedf01a10e3c8a0b6890b08c7f3f96d36a213699dbcd94/wrapt-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:955f1d6e72a352e478de8d8b503abe301c5e139a141b62eb0923bd694995025f", size = 80131, upload-time = "2026-06-20T23:47:41.785Z" }, - { url = "https://files.pythonhosted.org/packages/7f/73/8db7e27daef37ae70a53ea62bef7fe80cc51a8b5e9e9181a8be6eb9a999c/wrapt-2.2.2-cp310-cp310-win_arm64.whl", hash = "sha256:b89d8d73c82db2bb7e6090b3afd7973f980d24e905cc34394eab60b884b3bf67", size = 79615, upload-time = "2026-06-20T23:47:43.109Z" }, { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, @@ -2782,25 +2569,6 @@ version = "3.8.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/97/1a8cebf0a6650417f08a18231590e2515aacd5ce39c3ad8b9e013ebd437d/xxhash-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:27a9e475157f7315826118e3f3127909a0fe25f1b43d3d3be9c584f9d265f937", size = 34695, upload-time = "2026-07-06T10:43:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/2f/cf/745b9bc0dd9c341bc074b5fc700db7bbef0f3b69ab21446492296ab37e50/xxhash-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b2ce44bf8f4a1d01f418b3110ff8dff32fd3f3e836c0e06333c3725f243fa6c", size = 32376, upload-time = "2026-07-06T10:43:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/65/a4/8512a901b1d6ad4a9838d1b40385907a879d7e005a5afbec5d39526b69f6/xxhash-3.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:942bc86e9be6fdd6e1175048f5fe8f8fdaaf2309dd1323ef1e155a69cd346780", size = 217470, upload-time = "2026-07-06T10:43:43.572Z" }, - { url = "https://files.pythonhosted.org/packages/a0/ad/0ffd8094ea29579bb2dc42fa74d08570e9ea3d95db561e6b1105e69b9ca6/xxhash-3.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0204701e6d01f64254e0e5ff4255812b1febe027ddd7dda63372e27f98b5e91f", size = 237799, upload-time = "2026-07-06T10:43:45.248Z" }, - { url = "https://files.pythonhosted.org/packages/b3/90/783c6b3f9336bd07449fe672be32cef6833633936bbfda8d3b23ee18d202/xxhash-3.8.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dc4bdf008f77c88d544849c48c1a40faf25a5eff6cc466de2e8edc37c191fce", size = 262587, upload-time = "2026-07-06T10:43:46.733Z" }, - { url = "https://files.pythonhosted.org/packages/c4/77/ba0316a7c3e661b86830a47ae4987798616ce1b15af8d2a6358e2d89ef60/xxhash-3.8.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c566b123dce7e4867ca518434cdfb9f84e5023771235b2e3107a26c9a41cbd8", size = 238484, upload-time = "2026-07-06T10:43:48.453Z" }, - { url = "https://files.pythonhosted.org/packages/09/79/33001037c1cba90f4ced38b257161c13452024c0db44208f883e2e47f3fc/xxhash-3.8.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f23083e1bd9d901f844af7a126727c486e7eada9a1a6791c8f7e73f94fac656", size = 469909, upload-time = "2026-07-06T10:43:50.188Z" }, - { url = "https://files.pythonhosted.org/packages/45/90/237eded9dd6ae638083294e5a9f77b317aaebd480a330806b39c192a0de1/xxhash-3.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64af54dd1c3a45a27c04942f9a1a4683322bdd127f4745cca4e02549c1d2d2bb", size = 217166, upload-time = "2026-07-06T10:43:51.816Z" }, - { url = "https://files.pythonhosted.org/packages/0b/6a/8cb439dc9920e1468e1c2d69ef77cbeb4be3b1ae9f4b5344c07a2b59af18/xxhash-3.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8ea8a141eeced4f6262ab6dd71c681ac546a558c30bb586abe087d814b5f85ea", size = 307593, upload-time = "2026-07-06T10:43:53.436Z" }, - { url = "https://files.pythonhosted.org/packages/ec/c6/c0607d373c8affea92101a3926c4fc8b026bcf8983e05fd58f3a0380ebf8/xxhash-3.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a98b2f95cab589e0f5e92c48431afb4d56238b8bf6668edcc66166180e9b509b", size = 234702, upload-time = "2026-07-06T10:43:55.042Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cb/f4cfd456624c1f017858168b7ba9443dad810da8aac779a612658450e827/xxhash-3.8.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1b86ae798a976ccbc1d02af6ccb98f5b4d24756b1f65e995f11d10fe071f486f", size = 265749, upload-time = "2026-07-06T10:43:56.749Z" }, - { url = "https://files.pythonhosted.org/packages/33/f3/9006669c04b01206e21b2177425c649461ba188930a052c2f1728d6ec6a8/xxhash-3.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81f4ed9ca9644bc95cd976bfe10f7a4cafab8ffdc3aed52877d4600e445be7ef", size = 221992, upload-time = "2026-07-06T10:43:58.12Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0b/7e6f3eaa05df5e0b6c94aa452b0672801f7031e602081f07fd441aaaaed5/xxhash-3.8.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:cb3fe820c27593f170770d6c8d791936cf6275d9269405fbb7b30a55363c10c8", size = 236899, upload-time = "2026-07-06T10:43:59.562Z" }, - { url = "https://files.pythonhosted.org/packages/da/cc/bbaee4987f3aab1d7b33bb430bb49e940646160af448b9167431c931126d/xxhash-3.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7345007c12780985de4fd740148776d1eee18c0d41407c6fa1e48c5450304fe5", size = 297934, upload-time = "2026-07-06T10:44:01.132Z" }, - { url = "https://files.pythonhosted.org/packages/a7/97/6bee358660eb8b4f73c00b00b00bc616ebde00e1ab4b67c63486ce360648/xxhash-3.8.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:12eaeaa9ab8b9e6033a1fa5f6b338aaf55ff4df4bee11b59fd6ee03b19186ee4", size = 439315, upload-time = "2026-07-06T10:44:02.878Z" }, - { url = "https://files.pythonhosted.org/packages/c6/50/7e35275f39256bedace0c3cd5be3c72d4ac9d5aecf5e5fdc3530337cd263/xxhash-3.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2a845687219ba3214126f14a8a5861f97c9e065a7d0b8252adb6df13eea86fb", size = 214038, upload-time = "2026-07-06T10:44:04.504Z" }, - { url = "https://files.pythonhosted.org/packages/59/2d/69d02d096ee50bdf3ef0d208d874f52c71b1aa6906066bce3c52fedb8bc6/xxhash-3.8.1-cp310-cp310-win32.whl", hash = "sha256:656256c9f9303e47f07d5cb8ae4468285370adfafd7ba48aea33a458e7697626", size = 31939, upload-time = "2026-07-06T10:44:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1d/e06fca9844919ca91c6587d530cfa1e745830ec73ad38f44f04b25d1bfb7/xxhash-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:27cfc2f1ed76f956f36dfe0c56e5f5a3e94cd91eb78b893f63e2ef2ae404fcdf", size = 32729, upload-time = "2026-07-06T10:44:07.621Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c2/800648d99039927b5a86d8ae02cd86a556a5ee1678d388216f6b44c8966c/xxhash-3.8.1-cp310-cp310-win_arm64.whl", hash = "sha256:c85949d02c85adf6d786eb94858e124989a632a4e65739835b2fc5761827fac3", size = 29215, upload-time = "2026-07-06T10:44:08.916Z" }, { url = "https://files.pythonhosted.org/packages/8a/5a/05eaa129555f85476a3e16ff869e95f81a78bbe4647eef9d0229f515a317/xxhash-3.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602efcad4a42c184e81d43a2b7e6e4f524d619878f2b6ee2ba469011f47c8147", size = 34699, upload-time = "2026-07-06T10:44:10.14Z" }, { url = "https://files.pythonhosted.org/packages/80/59/0df1133958b2228929355e022aab1e958c7b2c43e27bf7f59bc9edfa8a54/xxhash-3.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:131324f719957b988861714de7d6ddf57b47abec3b0cc691302ffeaba0e05e10", size = 32373, upload-time = "2026-07-06T10:44:11.353Z" }, { url = "https://files.pythonhosted.org/packages/3e/bf/1cfda5b5e6bf26617812b4a31662ef2220d2ad04e0a55b8ff9eb36e56a5c/xxhash-3.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db77278a6eddadbf44ce5aae2fee5ebb4d061f026b1ce2130d058cd4d7a7b670", size = 220284, upload-time = "2026-07-06T10:44:12.683Z" }, @@ -2939,22 +2707,6 @@ version = "0.25.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, - { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, - { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, - { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, - { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, - { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, - { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, - { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, - { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },