diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b68d711..7a57b32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,16 @@ defaults: jobs: check: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready --health-interval 2s --health-timeout 5s --health-retries 30 + env: + ZERO_PYTHON_PG: postgresql://postgres:postgres@localhost:5432 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.gitignore b/.gitignore index 8963c24..7a22a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ .ruff_cache/ .coverage node_modules/ +runner.log diff --git a/harness/mutate.ts b/harness/mutate.ts new file mode 100644 index 0000000..5dfb72e --- /dev/null +++ b/harness/mutate.ts @@ -0,0 +1,98 @@ +// The mutate half of the wire tier: the published handleMutateRequest over the postgres.js adapter, +// with mutators that tests/wire/mutators.py mirrors one for one. +import {createSchema, string, table} from '@rocicorp/zero'; +import { + ApplicationError, + handleMutateRequest, + type Database, + type MutateRequestHandler, + type TransactionProviderHooks, + type TransactionProviderInput, +} from '@rocicorp/zero/server'; +import {zeroPostgresJS} from '@rocicorp/zero/server/adapters/postgresjs'; +import postgres from 'postgres'; + +const item = table('item').columns({id: string(), value: string()}).primaryKey('id'); +export const wireSchema = createSchema({tables: [item]}); + +type Tx = {dbTransaction: {query(sql: string, params: unknown[]): Promise}}; +type Args = {id?: string; value?: string; message?: string; details?: unknown}; + +const PUT = `INSERT INTO item (id, value) VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET value = item.value || '+' || excluded.value`; + +async function put(tx: Tx, args: Args) { + await tx.dbTransaction.query(PUT, [String(args.id ?? 'x'), String(args.value ?? '')]); +} + +const mutators: Record Promise> = { + 'item.put': put, + 'item.appError': async (tx, args) => { + await put(tx, args); + throw new ApplicationError(String(args.message ?? 'refused'), {details: args.details as never}); + }, + 'item.error': async (tx, args) => { + await put(tx, args); + throw new Error(String(args.message ?? 'broke')); + }, + 'item.typeError': async (tx, args) => { + await put(tx, args); + throw new TypeError(String(args.message ?? 'bad type')); + }, + 'item.dbError': async tx => { + await tx.dbTransaction.query('SELECT * FROM missing_table', []); + }, +}; + +const handler: MutateRequestHandler> = async (transact, mutation) => { + const args = (mutation.args[0] ?? {}) as Args; + if (mutation.name === 'pre.appError') { + throw new ApplicationError(String(args.message ?? 'pre'), {details: args.details as never}); + } + if (mutation.name === 'pre.error') { + throw new Error(String(args.message ?? 'pre')); + } + const result = await transact(async (tx, name, mutatorArgs) => { + const mutator = mutators[name.replace(/^post\./, 'item.')]; + if (!mutator) throw new Error(`unknown mutator ${name}`); + await mutator(tx, (mutatorArgs ?? {}) as Args); + }); + if (mutation.name.startsWith('post.')) { + throw new Error('after commit'); + } + return result; +}; + +const providers = new Map>(); + +/** The database for `url`, failing to open any transaction for client `cfail`. */ +function provider(url: string): Database { + let db = providers.get(url); + if (!db) { + const zql = zeroPostgresJS(wireSchema, postgres(url, {onnotice: () => {}})); + db = { + transaction: (cb, input?: TransactionProviderInput) => + input?.clientID === 'cfail' + ? Promise.reject(new Error('cannot open')) + : zql.transaction(cb as never, input), + } as Database; + providers.set(url, db); + } + return db; +} + +export function mutate({ + url, + query, + body, + userID, +}: { + url: string; + query: Record; + body: unknown; + userID: string | null; +}) { + return handleMutateRequest({dbProvider: provider(url), handler, query, body: body as never, userID, logLevel: 'error'}); +} + +export type {TransactionProviderHooks}; diff --git a/harness/ops.ts b/harness/ops.ts index 8d85dfb..c9625c9 100644 --- a/harness/ops.ts +++ b/harness/ops.ts @@ -9,6 +9,7 @@ import { transformRequestMessageSchema, v, } from './zero.ts'; +import {mutate} from './mutate.ts'; // The schemas upstream parses requests with, and the mode it parses each in. const schemas = { @@ -20,6 +21,8 @@ const schemas = { } as const; export const ops = { + mutate, + /** Whether upstream accepts `value` as a `schema`. */ accepts({schema, value}: {schema: keyof typeof schemas; value: unknown}) { const [type, mode] = schemas[schema]; diff --git a/harness/package-lock.json b/harness/package-lock.json index 95dc70d..5a7edab 100644 --- a/harness/package-lock.json +++ b/harness/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "zero-python-harness", "dependencies": { - "@rocicorp/zero": "1.10.0" + "@rocicorp/zero": "1.10.0", + "postgres": "3.4.7" } }, "node_modules/@badrap/valita": { diff --git a/harness/package.json b/harness/package.json index 58e1871..e866257 100644 --- a/harness/package.json +++ b/harness/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "dependencies": { - "@rocicorp/zero": "1.10.0" + "@rocicorp/zero": "1.10.0", + "postgres": "3.4.7" } } diff --git a/harness/runner.ts b/harness/runner.ts index bce6ce7..eb8e0cb 100644 --- a/harness/runner.ts +++ b/harness/runner.ts @@ -1,6 +1,10 @@ // The TypeScript half of the differential tests. Reads one JSON request per line on stdin and writes one // JSON response per line on stdout: {ok: true, value} or {ok: false, error}. import {createInterface} from 'node:readline'; + +// stdout carries the protocol, so upstream's logging goes to stderr. +console.log = console.info = console.debug = console.warn = console.error; + import {ops} from './ops.ts'; const lines = createInterface({input: process.stdin, crlfDelay: Infinity}); @@ -16,3 +20,5 @@ for await (const line of lines) { } process.stdout.write(JSON.stringify(response) + '\n'); } +// Open database pools would otherwise keep the process alive once stdin closes. +process.exit(0); diff --git a/pyproject.toml b/pyproject.toml index 265dd86..25828b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,12 @@ classifiers = [ ] dependencies = ["pydantic>=2.14"] +[project.optional-dependencies] +sqlalchemy = ["sqlalchemy[asyncio]>=2.1", "asyncpg>=0.32"] + [dependency-groups] dev = [ + "zero-python[sqlalchemy]", "hypothesis>=6.169", "pytest>=9.1", "pytest-asyncio>=1.4", @@ -45,6 +49,10 @@ asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" addopts = "--cov=zero_python --cov-branch --cov-report=term-missing" +[tool.coverage.run] +# SQLAlchemy's asyncio layer resumes coroutines inside greenlets. +concurrency = ["greenlet", "thread"] + [tool.coverage.report] fail_under = 100 skip_covered = true diff --git a/src/zero_python/server/__init__.py b/src/zero_python/server/__init__.py new file mode 100644 index 0000000..ce003a6 --- /dev/null +++ b/src/zero_python/server/__init__.py @@ -0,0 +1 @@ +"""The server SDK: what `@rocicorp/zero/server` gives a TypeScript backend.""" diff --git a/src/zero_python/server/process_mutations.py b/src/zero_python/server/process_mutations.py new file mode 100644 index 0000000..5f65258 --- /dev/null +++ b/src/zero_python/server/process_mutations.py @@ -0,0 +1,397 @@ +"""The mutate endpoint: exactly-once processing of a push from zero-cache. + +Ported from rocicorp/mono `packages/zero-server/src/process-mutations.ts` at zero/v1.10.0 +(905d08516ba0caaf314cd28e93a5a47e8acdd482), with `MutationAlreadyProcessedError` from +`packages/zero-cache/src/services/mutagen/error.ts`. Apache-2.0, Copyright Rocicorp. + +Each mutation runs inside the same database transaction that bumps its client's `lastMutationID`. +The bump comes first and the comparison second, so a duplicate or a gap is found after the bump and +undone by rolling that transaction back. A mutation whose mutator fails is retried without the +mutator: the second transaction takes the ID and records the error in the `mutations` table, which is +where the client's pending mutation learns it failed. +""" + +import json +import logging +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass +from typing import Any, Literal, Protocol + +from pydantic import TypeAdapter, ValidationError + +from zero_python.protocol.application_error import ApplicationError, wrap_with_application_error +from zero_python.protocol.error import ErrorReason, ServerPushFailed +from zero_python.protocol.mutate_server import MutateParams, MutateSuccess +from zero_python.protocol.mutation import ( + CLEANUP_RESULTS_MUTATION_NAME, + CleanupResultsArg, + CleanupResultsBulkArg, + CustomMutation, + MutationAppError, + MutationOk, + MutationResponse, + MutationZeroError, +) +from zero_python.protocol.mutation_id import MutationID +from zero_python.protocol.push import PushBody +from zero_python.protocol.wire import parse_wire +from zero_python.shared.asserts import check +from zero_python.shared.error import get_error_details, get_error_message +from zero_python.shared.js import js_number, js_truthy +from zero_python.shared.json import JSONValue + +logger = logging.getLogger("zero_python.mutate") + +_cleanup_results_arg: TypeAdapter[CleanupResultsArg] = TypeAdapter(CleanupResultsArg) + + +class TransactionProviderHooks(Protocol): + """The three statements against zero-cache's bookkeeping tables, run in the mutation's transaction.""" + + async def update_client_mutation_id(self) -> int: ... + + async def write_mutation_result(self, result: MutationResponse) -> None: ... + + async def delete_mutation_results(self, args: CleanupResultsArg) -> None: ... + + +@dataclass(frozen=True) +class TransactionProviderInput: + upstream_schema: str + client_group_id: str + client_id: str + mutation_id: int | float + + +class Database[T](Protocol): + """A database `handle_mutate_request` can run transactions against. + + `transaction` opens one transaction, calls `callback` inside it with the app's transaction object and + the bookkeeping hooks, and commits when `callback` returns. + """ + + async def transaction[R]( + self, + callback: Callable[[T, TransactionProviderHooks], Awaitable[R]], + transaction_input: TransactionProviderInput | None = None, + ) -> R: ... + + +type TransactFnCallback[T] = Callable[[T, str, JSONValue | None], Awaitable[None]] +type TransactFn[T] = Callable[[TransactFnCallback[T]], Awaitable[MutationResponse]] +type MutateRequestHandler[T] = Callable[[TransactFn[T], CustomMutation], Awaitable[MutationResponse]] +"""Runs once per custom mutation. + +Call `transact` to run the mutation's database work. Raising before `transact` records an application +error for the mutation; raising after it only reaches the log, because the mutation has committed. +""" + +type MutateResponse = MutateSuccess | ServerPushFailed + +type _DatabaseTransactionPhase = Literal["open", "execute"] +type _MutationPhase = Literal["preTransaction", "transactionPending", "postCommit"] + + +class OutOfOrderMutation(Exception): + error_name = "Error" + + def __init__(self, client_id: str, received_mutation_id: float, last_mutation_id: float) -> None: + super().__init__( + f"Client {client_id} sent mutation ID {js_number(received_mutation_id)} " + f"but expected {js_number(last_mutation_id)}" + ) + + +class MutationAlreadyProcessedError(Exception): + error_name = "Error" + + def __init__(self, client_id: str, received: float, actual: float) -> None: + super().__init__( + f"Ignoring mutation from {client_id} with ID {js_number(received)} as it was already processed. " + f"Expected: {js_number(actual)}" + ) + check( + received < actual, + f"MutationAlreadyProcessedError: received ({js_number(received)}) must be < actual ({js_number(actual)})", + ) + + +class DatabaseTransactionError(Exception): + def __init__(self, phase: _DatabaseTransactionPhase, cause: BaseException) -> None: + message = ( + f"Failed to open database transaction: {get_error_message(cause)}" + if phase == "open" + else f"Database transaction failed after opening: {get_error_message(cause)}" + ) + super().__init__(message) + self.__cause__ = cause + + +_PASSES_THROUGH = (DatabaseTransactionError, OutOfOrderMutation, MutationAlreadyProcessedError, ApplicationError) + + +async def _application_error_wrapper[R](fn: Callable[[], Awaitable[R]]) -> R: + try: + return await fn() + except _PASSES_THROUGH: + raise + except Exception as error: + raise wrap_with_application_error(error) from error + + +def _refuse_constant(name: str) -> None: + raise ValueError(f"{name} is not valid JSON") + + +def _parse_failure( + subject: str, error: BaseException, mutation_ids: list[MutationID], *, syntax: bool = False +) -> ServerPushFailed: + return ServerPushFailed( + reason=ErrorReason.PARSE, + message=f"Failed to parse {subject}: {get_error_message(error)}", + mutationIDs=mutation_ids, + # Upstream's parsers throw a `TypeError`, and `JSON.parse` a `SyntaxError`. + details={"name": "SyntaxError" if syntax else "TypeError"}, + ) + + +async def handle_mutate_request[T]( + *, + db_provider: Database[T], + handler: MutateRequestHandler[T], + query: Mapping[str, str], + body: bytes | JSONValue, + user_id: str | None, +) -> MutateResponse: + """Process one `/mutate` request from zero-cache. + + `query` is the request's query string and `body` its JSON, either raw bytes or already parsed. + `user_id` is the authenticated user, `None` when logged out; reporting it back is what makes + zero-cache treat the connection's user as server-validated. + """ + json_body: Any = body + if isinstance(body, bytes): + try: + json_body = json.loads(body, parse_constant=_refuse_constant) + except ValueError as error: + logger.error("Failed to parse push body", exc_info=error) + return _parse_failure("push body", error, [], syntax=True) + + mutation_ids: list[MutationID] = [] + try: + push_body = PushBody.from_json(json_body) + mutation_ids = [MutationID(id=m.id, clientID=m.client_id) for m in push_body.mutations] + except ValidationError as error: + logger.error("Failed to parse push body", exc_info=error) + return _parse_failure("push body", error, mutation_ids) + + try: + params = MutateParams.from_json(dict(query)) + except ValidationError as error: + logger.error("Failed to parse push query parameters", exc_info=error) + return _parse_failure("push query parameters", error, mutation_ids) + + if push_body.push_version != 1: + return ServerPushFailed( + reason=ErrorReason.UNSUPPORTED_PUSH_VERSION, + mutationIDs=mutation_ids, + message=f"Unsupported push version: {js_number(push_body.push_version)}", + ) + + responses: list[MutationResponse] = [] + processed_count = 0 + + try: + transactor = _Transactor(db_provider, push_body, params) + + for m in push_body.mutations: + if m.type == "custom" and m.name == CLEANUP_RESULTS_MUTATION_NAME: + try: + await _process_cleanup_results_mutation(db_provider, m, params) + except Exception as error: + # A failed cleanup costs disk, not correctness, so it does not fail the push. + logger.warning("Failed to process cleanup mutation for client %s", m.client_id, exc_info=error) + processed_count += 1 + continue + + check(m.type == "custom", "Expected custom mutation") + assert isinstance(m, CustomMutation) + response = await _process_one(transactor, handler, m) + responses.append(response) + processed_count += 1 + + return MutateSuccess(mutations=responses, userID=user_id) + except Exception as error: + logger.error("Failed to process push request", exc_info=error) + details = get_error_details(error) + return ServerPushFailed( + reason=( + ErrorReason.OUT_OF_ORDER_MUTATION + if isinstance(error, OutOfOrderMutation) + else ErrorReason.DATABASE + if isinstance(error, DatabaseTransactionError) + else ErrorReason.INTERNAL + ), + message=get_error_message(error), + # Only the mutations not yet processed. + mutationIDs=mutation_ids[processed_count:], + # Upstream drops falsy details, `0` and `""` included. + details=details if js_truthy(details) else None, + ) + + +async def _process_one[T]( + transactor: "_Transactor[T]", handler: MutateRequestHandler[T], m: CustomMutation +) -> MutationResponse: + phase: _MutationPhase = "preTransaction" + + async def transact(callback: TransactFnCallback[T]) -> MutationResponse: + nonlocal phase + phase = "transactionPending" + result = await transactor.transact(m, callback) + phase = "postCommit" + return result + + try: + return await _application_error_wrapper(lambda: handler(transact, m)) + except ApplicationError as error: + if phase == "preTransaction": + await transactor.persist_pre_transaction_failure(m, error) + elif phase == "postCommit": # pragma: no branch - `transact` never raises an ApplicationError + logger.error( + "Post-commit mutation handler failed for mutation %s for client %s", m.id, m.client_id, exc_info=error + ) + logger.warning("Application error processing mutation %s for client %s", m.id, m.client_id, exc_info=error) + return _app_error_response(m, error) + + +class _Transactor[T]: + def __init__(self, db_provider: Database[T], push: PushBody, params: MutateParams) -> None: + self._db = db_provider + self._push = push + self._params = params + + async def transact(self, mutation: CustomMutation, callback: TransactFnCallback[T]) -> MutationResponse: + app_error: ApplicationError | None = None + while True: + try: + result = await self._transact_impl(mutation, callback, app_error) + if app_error is not None: + logger.warning( + "Mutation %s for client %s was retried after an error", + mutation.id, + mutation.client_id, + exc_info=app_error, + ) + return _app_error_response(mutation, app_error) + return result + except OutOfOrderMutation as error: + logger.error("%s", error) + raise + except MutationAlreadyProcessedError as error: + logger.warning("%s", error) + return MutationResponse( + id=MutationID(clientID=mutation.client_id, id=mutation.id), + result=MutationZeroError(error="alreadyProcessed", details=str(error)), + ) + except Exception as error: + if app_error is not None: + # The retry, which runs no mutator, failed too: nothing to skip to. + logger.error( + "Retry also failed for mutation %s for client %s", + mutation.id, + mutation.client_id, + exc_info=error, + ) + raise + original: BaseException = error + if isinstance(error, DatabaseTransactionError) and error.__cause__ is not None: + original = error.__cause__ + app_error = wrap_with_application_error(original) + logger.warning( + "Error processing mutation %s for client %s, retrying without mutator", + mutation.id, + mutation.client_id, + exc_info=app_error, + ) + + async def persist_pre_transaction_failure(self, mutation: CustomMutation, app_error: ApplicationError) -> None: + """The handler raised before calling `transact`. The ID still advances and the failure is recorded.""" + + await self._transact_impl(mutation, None, app_error) + + async def _transact_impl( + self, mutation: CustomMutation, callback: TransactFnCallback[T] | None, app_error: ApplicationError | None + ) -> MutationResponse: + """Bump the ID, then run `callback`, or with `app_error` set, record that error in its place.""" + phase: _DatabaseTransactionPhase = "open" + + async def run(tx: T, hooks: TransactionProviderHooks) -> MutationResponse: + nonlocal phase + phase = "execute" + await self._check_and_increment_last_mutation_id(hooks, mutation.client_id, mutation.id) + if app_error is None: + assert callback is not None + logger.debug("Executing mutator '%s' (id=%s)", mutation.name, mutation.id) + await callback(tx, mutation.name, mutation.args[0] if mutation.args else None) + else: + await hooks.write_mutation_result(_app_error_response(mutation, app_error)) + return MutationResponse(id=MutationID(clientID=mutation.client_id, id=mutation.id), result=MutationOk()) + + try: + return await self._db.transaction( + run, + TransactionProviderInput( + upstream_schema=self._params.schema_, + client_group_id=self._push.client_group_id, + client_id=mutation.client_id, + mutation_id=mutation.id, + ), + ) + except (ApplicationError, OutOfOrderMutation, MutationAlreadyProcessedError): + raise + except Exception as error: + raise DatabaseTransactionError(phase, error) from error + + async def _check_and_increment_last_mutation_id( + self, hooks: TransactionProviderHooks, client_id: str, received: float + ) -> None: + last_mutation_id = await hooks.update_client_mutation_id() + if received < last_mutation_id: + raise MutationAlreadyProcessedError(client_id, received, last_mutation_id) + if received > last_mutation_id: + raise OutOfOrderMutation(client_id, received, last_mutation_id) + + +def _app_error_response(m: CustomMutation, error: ApplicationError) -> MutationResponse: + return MutationResponse( + id=MutationID(clientID=m.client_id, id=m.id), + result=MutationAppError(message=error.message, details=error.details if js_truthy(error.details) else None), + ) + + +async def _process_cleanup_results_mutation[T]( + db_provider: Database[T], mutation: CustomMutation, params: MutateParams +) -> None: + """Deletes acknowledged results. An internal mutation, so it never touches `lastMutationID`.""" + try: + args = parse_wire(_cleanup_results_arg, mutation.args[0] if mutation.args else None) + except ValidationError as error: + logger.warning("Cleanup mutation has invalid args", exc_info=error) + return + + client_id = args.client_ids[0] if isinstance(args, CleanupResultsBulkArg) else args.client_id + + async def delete(_tx: T, hooks: TransactionProviderHooks) -> None: + await hooks.delete_mutation_results(args) + + # Only the upstream schema matters to the delete; the rest fills the interface. + await db_provider.transaction( + delete, + TransactionProviderInput( + upstream_schema=params.schema_, + client_group_id=args.client_group_id, + client_id=client_id, + mutation_id=0, + ), + ) diff --git a/src/zero_python/server/sqlalchemy.py b/src/zero_python/server/sqlalchemy.py new file mode 100644 index 0000000..f60c4ee --- /dev/null +++ b/src/zero_python/server/sqlalchemy.py @@ -0,0 +1,113 @@ +"""A transaction provider over SQLAlchemy's asyncio engine, with the asyncpg driver. + +Ported from rocicorp/mono `packages/zero-server/src/zql-database.ts` and +`packages/zero-server/src/adapters/postgresjs.ts` at zero/v1.10.0 +(905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp. +""" + +from collections.abc import Awaitable, Callable, Sequence +from typing import Any + +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine + +from zero_python.protocol.mutation import CleanupResultsArg, CleanupResultsBulkArg, MutationResponse +from zero_python.server.process_mutations import TransactionProviderHooks, TransactionProviderInput +from zero_python.server.transaction import Row, ServerTransaction +from zero_python.shared.js import js_stringify + + +class PostgresError(Exception): + """A statement Postgres refused. Named as the postgres.js adapter names its errors upstream.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + self.message = message + + +def ident(name: str) -> str: + """Quote an identifier as upstream's `sql.ident` does, splitting a dotted name into its parts.""" + return ".".join('"' + part.replace('"', '""') + '"' for part in name.split(".")) + + +class SQLAlchemyTransaction: + def __init__(self, connection: AsyncConnection) -> None: + self._connection = connection + + @property + def wrapped_transaction(self) -> AsyncConnection: + return self._connection + + async def query(self, sql: str, params: Sequence[Any] = ()) -> list[Row]: + try: + result = await self._connection.exec_driver_sql(sql, tuple(params)) + except DBAPIError as error: + cause = error.orig.__cause__ if error.orig is not None and error.orig.__cause__ is not None else error.orig + raise PostgresError(str(cause)) from error + return [dict(row) for row in result.mappings()] if result.returns_rows else [] + + +class _Hooks: + def __init__(self, tx: SQLAlchemyTransaction, transaction_input: TransactionProviderInput) -> None: + self._tx = tx + self._input = transaction_input + self._schema = ident(transaction_input.upstream_schema) + + async def update_client_mutation_id(self) -> int: + rows = await self._tx.query( + f"""INSERT INTO {self._schema}.clients + as current ("clientGroupID", "clientID", "lastMutationID") + VALUES ($1, $2, $3) + ON CONFLICT ("clientGroupID", "clientID") + DO UPDATE SET "lastMutationID" = current."lastMutationID" + 1 + RETURNING "lastMutationID\"""", + (self._input.client_group_id, self._input.client_id, 1), + ) + return int(rows[0]["lastMutationID"]) + + async def write_mutation_result(self, result: MutationResponse) -> None: + await self._tx.query( + f"""INSERT INTO {self._schema}.mutations + ("clientGroupID", "clientID", "mutationID", "result") + VALUES ($1, $2, $3, $4::text::json)""", + ( + self._input.client_group_id, + result.id.client_id, + result.id.id, + js_stringify(result.result.to_json()), + ), + ) + + async def delete_mutation_results(self, args: CleanupResultsArg) -> None: + if isinstance(args, CleanupResultsBulkArg): + await self._tx.query( + f"""DELETE FROM {self._schema}."mutations" + WHERE "clientGroupID" = $1 AND "clientID" = ANY($2)""", + (args.client_group_id, list(args.client_ids)), + ) + else: + await self._tx.query( + f"""DELETE FROM {self._schema}."mutations" + WHERE "clientGroupID" = $1 AND "clientID" = $2 AND "mutationID" <= $3""", + (args.client_group_id, args.client_id, args.up_to_mutation_id), + ) + + +class SQLAlchemyDatabase: + """Runs each mutation in one transaction on `engine`, which must use the asyncpg driver.""" + + def __init__(self, engine: AsyncEngine) -> None: + self.engine = engine + + async def transaction[R]( + self, + callback: Callable[[ServerTransaction[AsyncConnection], TransactionProviderHooks], Awaitable[R]], + transaction_input: TransactionProviderInput | None = None, + ) -> R: + transaction_input = transaction_input or TransactionProviderInput( + upstream_schema="", client_group_id="", client_id="", mutation_id=0 + ) + async with self.engine.begin() as connection: + tx = SQLAlchemyTransaction(connection) + server_tx = ServerTransaction(tx, transaction_input.client_id, int(transaction_input.mutation_id)) + return await callback(server_tx, _Hooks(tx, transaction_input)) diff --git a/src/zero_python/server/transaction.py b/src/zero_python/server/transaction.py new file mode 100644 index 0000000..6408055 --- /dev/null +++ b/src/zero_python/server/transaction.py @@ -0,0 +1,30 @@ +"""The transaction a server mutator receives. + +Ported from rocicorp/mono `packages/zero-server/src/custom.ts` (`TransactionImpl`) and +`packages/zql/src/mutate/custom.ts` at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482). +Apache-2.0, Copyright Rocicorp. +""" + +from collections.abc import Sequence +from typing import Any, Literal, Protocol + +type Row = dict[str, Any] + + +class DBTransaction[W](Protocol): + """One open database transaction. `query` takes SQL with `$1`-style placeholders, as upstream's does.""" + + @property + def wrapped_transaction(self) -> W: ... + + async def query(self, sql: str, params: Sequence[Any] = ()) -> list[Row]: ... + + +class ServerTransaction[W]: + location: Literal["server"] = "server" + reason: Literal["authoritative"] = "authoritative" + + def __init__(self, db_transaction: DBTransaction[W], client_id: str, mutation_id: int) -> None: + self.db_transaction = db_transaction + self.client_id = client_id + self.mutation_id = mutation_id diff --git a/src/zero_python/shared/error.py b/src/zero_python/shared/error.py index e98645e..5ce56bc 100644 --- a/src/zero_python/shared/error.py +++ b/src/zero_python/shared/error.py @@ -8,9 +8,9 @@ its upstream counterpart reports, which is `"Error"` for the upstream classes that never set `this.name`. """ -import json from typing import Any +from zero_python.shared.js import js_stringify from zero_python.shared.json import JSONValue, is_json_value @@ -37,15 +37,31 @@ def _message(error: object, seen: set[int]) -> str: if isinstance(message, str) and message: return message - if is_json_value(error): - return f"Parsed message: {json.dumps(error, separators=(',', ':'), ensure_ascii=False)}" + as_object = enumerable_properties(error) if isinstance(error, BaseException) else error + if is_json_value(as_object): + return f"Parsed message: {js_stringify(as_object)}" return ( - f"Unknown error of type {type(error).__name__} was thrown and the message could not be determined. " + f"Unknown error of type {js_typeof(error)} was thrown and the message could not be determined. " "See cause for details." ) +def enumerable_properties(error: BaseException) -> dict[str, Any]: + """What `JSON.stringify` would see of an error: its own attributes, less those JavaScript hides. + + `message` is never enumerable on a JavaScript error, and `ApplicationError` keeps its details in a + private field. + """ + hidden = {"message", "details"} if type(error).__name__ == "ApplicationError" else {"message"} + return {k: v for k, v in vars(error).items() if not k.startswith("_") and k not in hidden} + + +def js_typeof(value: object) -> str: + """`typeof` for what reaches the unknown-error path, which is never a JSON value.""" + return "function" if callable(value) else "object" + + def _exception_message(error: BaseException) -> str: message = getattr(error, "message", None) if isinstance(message, str): diff --git a/src/zero_python/shared/js.py b/src/zero_python/shared/js.py index bef4a70..7aafc5b 100644 --- a/src/zero_python/shared/js.py +++ b/src/zero_python/shared/js.py @@ -1,5 +1,6 @@ """JavaScript's `String(value)`, for the places upstream compares stringified values.""" +import json from collections.abc import Callable from decimal import Decimal from functools import cmp_to_key @@ -77,3 +78,42 @@ def js_sort[T](items: list[T], compare: Callable[[T, T], int]) -> list[T]: out[left + 1 : start + 1] = out[left:start] out[left] = pivot return out + + +def js_truthy(value: object) -> bool: + """JavaScript's truthiness for a JSON value: an empty array or object is truthy there.""" + if value is None or value is False or value == "": + return False + if isinstance(value, int | float) and not isinstance(value, bool): + return value == value and value != 0 # NaN is falsy + return True + + +def js_stringify(value: object) -> str: + """`JSON.stringify(value)` for a JSON value: compact, numbers written as JavaScript writes them.""" + match value: + case None: + return "null" + case bool(): + return "true" if value else "false" + case int() | float(): + return js_number(value) + case str(): + return json.dumps(value, ensure_ascii=False) + case list() | tuple(): + return "[" + ",".join(js_stringify(item) for item in value) + "]" + case dict(): + members = (f"{json.dumps(k, ensure_ascii=False)}:{js_stringify(value[k])}" for k in js_keys(value)) + return "{" + ",".join(members) + "}" + case _: + raise TypeError(f"not a JSON value: {type(value).__name__}") + + +def _is_array_index(key: str) -> bool: + return key.isascii() and key.isdigit() and (key == "0" or key[0] != "0") and int(key) < 2**32 - 1 + + +def js_keys(obj: dict[str, object]) -> list[str]: + """`Object.keys(obj)`: array-index keys first in numeric order, then the rest as inserted.""" + indexes = sorted((k for k in obj if _is_array_index(k)), key=int) + return indexes + [k for k in obj if not _is_array_index(k)] diff --git a/tests/harness.py b/tests/harness.py index 9196209..5db2b9b 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -15,11 +15,14 @@ class HarnessError(Exception): class Harness: def __init__(self) -> None: + # Upstream logs every failure it handles; they land here rather than in the test output. + self._log = (HARNESS / "runner.log").open("w") self._process = subprocess.Popen( ["node", "runner.ts"], cwd=HARNESS, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=self._log, text=True, bufsize=1, ) @@ -38,4 +41,8 @@ def call(self, op: str, **args: Any) -> Any: def close(self) -> None: if self._process.stdin is not None: self._process.stdin.close() - self._process.wait(timeout=10) + try: + self._process.wait(timeout=10) + except subprocess.TimeoutExpired: + self._process.kill() + self._log.close() diff --git a/tests/postgres.py b/tests/postgres.py new file mode 100644 index 0000000..9ec68d9 --- /dev/null +++ b/tests/postgres.py @@ -0,0 +1,52 @@ +"""A Postgres for the tests: `ZERO_PYTHON_PG` if set (as in CI), otherwise a throwaway Docker container.""" + +import os +import subprocess +import time +import uuid +from collections.abc import Generator +from contextlib import contextmanager + +IMAGE = "postgres:17" + + +@contextmanager +def postgres_server() -> Generator[str]: + """Yields a base URL, `postgresql://user:password@host:port`, with no database on the end.""" + if url := os.environ.get("ZERO_PYTHON_PG"): + yield url.rstrip("/") + return + name = f"zero-python-test-{uuid.uuid4().hex[:8]}" + subprocess.run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + name, + "-e", + "POSTGRES_PASSWORD=postgres", + "-p", + "127.0.0.1::5432", + IMAGE, + ], + check=True, + capture_output=True, + ) + try: + port = ( + subprocess.run(["docker", "port", name, "5432/tcp"], check=True, capture_output=True, text=True) + .stdout.split(":")[-1] + .strip() + ) + for _ in range(120): + ready = subprocess.run( + ["docker", "exec", name, "pg_isready", "-U", "postgres", "-h", "127.0.0.1"], capture_output=True + ) + if ready.returncode == 0: + break + time.sleep(0.5) + yield f"postgresql://postgres:postgres@127.0.0.1:{port}" + finally: + subprocess.run(["docker", "rm", "-f", name], capture_output=True) diff --git a/tests/unit/test_mutate.py b/tests/unit/test_mutate.py new file mode 100644 index 0000000..928d15c --- /dev/null +++ b/tests/unit/test_mutate.py @@ -0,0 +1,43 @@ +"""Branches of the mutate endpoint the wire tier cannot reach: raw bodies upstream would read off a Request.""" + +from typing import Any + +import pytest + +from zero_python.protocol.mutation import CustomMutation, MutationResponse +from zero_python.server.process_mutations import TransactFn, handle_mutate_request + +QUERY = {"schema": "zero_0", "appID": "zero"} + + +class NoDatabase: + async def transaction(self, callback: Any, transaction_input: Any = None) -> Any: + raise AssertionError("no transaction expected") + + +async def handler(transact: TransactFn[Any], mutation: CustomMutation) -> MutationResponse: + raise AssertionError("no mutation expected") + + +@pytest.mark.parametrize("body", [b"{", b'{"a": NaN}', b"\xff"]) +async def test_unparseable_body(body: bytes) -> None: + response = await handle_mutate_request( + db_provider=NoDatabase(), handler=handler, query=QUERY, body=body, user_id=None + ) + assert response.to_json() == { + "kind": "PushFailed", + "origin": "server", + "reason": "parse", + "message": response.to_json()["message"], + "mutationIDs": [], + "details": {"name": "SyntaxError"}, + } + assert str(response.to_json()["message"]).startswith("Failed to parse push body: ") + + +async def test_raw_body_is_parsed() -> None: + body = b'{"clientGroupID": "g", "mutations": [], "pushVersion": 1, "timestamp": 1, "requestID": "r"}' + response = await handle_mutate_request( + db_provider=NoDatabase(), handler=handler, query=QUERY, body=body, user_id="u" + ) + assert response.to_json() == {"kind": "MutateResponse", "userID": "u", "mutations": []} diff --git a/tests/unit/test_shared.py b/tests/unit/test_shared.py index 10de48b..b019b4a 100644 --- a/tests/unit/test_shared.py +++ b/tests/unit/test_shared.py @@ -60,10 +60,17 @@ def test_error_message_falls_back_to_the_cause() -> None: assert get_error_message(error) == "root" -def test_error_message_with_an_empty_cause() -> None: +def test_error_message_of_an_empty_error_is_its_properties() -> None: error = Exception() error.__cause__ = Exception() - assert get_error_message(error).startswith("Unknown error of type Exception") + assert get_error_message(error) == "Parsed message: {}" + error = Exception() + error.code = 5 # ty: ignore[unresolved-attribute] + assert get_error_message(error) == 'Parsed message: {"code":5}' + error.code = object() # ty: ignore[unresolved-attribute] + assert get_error_message(error) == ( + "Unknown error of type object was thrown and the message could not be determined. See cause for details." + ) def test_error_message_detects_cycles() -> None: @@ -77,6 +84,7 @@ def test_error_message_from_objects() -> None: assert get_error_message({"message": ""}) == 'Parsed message: {"message":""}' assert get_error_message([1, "é"]) == 'Parsed message: [1,"é"]' assert get_error_message(object()).startswith("Unknown error of type object was thrown") + assert get_error_message(print).startswith("Unknown error of type function was thrown") def test_error_details() -> None: @@ -114,3 +122,12 @@ def test_js_sort_long_arrays_use_timsort() -> None: items = list(range(70, 0, -1)) assert js_sort(items, lambda a, b: a - b) == sorted(items) assert js_sort([1], lambda a, b: 0) == [1] + + +def test_js_stringify() -> None: + from zero_python.shared.js import js_stringify + + assert js_stringify({"a": [1.0, True, False, None, "é"], "b": 2.5}) == '{"a":[1,true,false,null,"é"],"b":2.5}' + assert js_stringify({"b": 1, "10": 2, "2": 3, "01": 4, "": 5}) == '{"2":3,"10":2,"b":1,"01":4,"":5}' + with pytest.raises(TypeError, match="not a JSON value"): + js_stringify(object()) diff --git a/tests/wire/conftest.py b/tests/wire/conftest.py new file mode 100644 index 0000000..ef1a114 --- /dev/null +++ b/tests/wire/conftest.py @@ -0,0 +1,96 @@ +import asyncio +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + +from tests.postgres import postgres_server + +DDL = [ + "CREATE SCHEMA zero_0", + """CREATE TABLE zero_0."clients" ( + "clientGroupID" TEXT NOT NULL, + "clientID" TEXT NOT NULL, + "lastMutationID" BIGINT NOT NULL, + "userID" TEXT, + PRIMARY KEY("clientGroupID", "clientID"))""", + """CREATE TABLE zero_0."mutations" ( + "clientGroupID" TEXT NOT NULL, + "clientID" TEXT NOT NULL, + "mutationID" BIGINT NOT NULL, + "result" JSON NOT NULL, + PRIMARY KEY("clientGroupID", "clientID", "mutationID"))""", + "CREATE TABLE item (id TEXT PRIMARY KEY, value TEXT NOT NULL)", +] + +RESET = 'TRUNCATE item, zero_0."clients", zero_0."mutations"' + + +@dataclass +class WireDB: + """One database per side, built identically, so each side's writes can be diffed.""" + + runner: asyncio.Runner + ts_url: str + py_engine: AsyncEngine + ts_engine: AsyncEngine + + def run(self, coro: Any) -> Any: + return self.runner.run(coro) + + def reset(self) -> None: + async def reset() -> None: + for engine in (self.py_engine, self.ts_engine): + async with engine.begin() as conn: + await conn.execute(text(RESET)) + + self.run(reset()) + + def dump(self, engine: AsyncEngine) -> dict[str, list[Any]]: + async def dump() -> dict[str, list[Any]]: + async with engine.connect() as conn: + queries = { + "clients": 'SELECT * FROM zero_0."clients" ORDER BY 1, 2', + "mutations": 'SELECT "clientGroupID", "clientID", "mutationID", "result"::text AS result ' + 'FROM zero_0."mutations" ORDER BY 1, 2, 3', + "item": "SELECT * FROM item ORDER BY id", + } + dumped = { + name: [dict(row) for row in (await conn.execute(text(sql))).mappings()] + for name, sql in queries.items() + } + # Compared as the exact text each side stored: `json` keeps it verbatim. + return dumped + + return self.run(dump()) + + +@pytest.fixture(scope="session") +def wire_db() -> Iterator[WireDB]: + with postgres_server() as base, asyncio.Runner() as runner: + + async def setup() -> tuple[AsyncEngine, AsyncEngine]: + admin = create_async_engine( + f"postgresql+asyncpg://{base.split('://', 1)[1]}/postgres", isolation_level="AUTOCOMMIT" + ) + async with admin.connect() as conn: + for name in ("zp_wire_ts", "zp_wire_py"): + await conn.execute(text(f"DROP DATABASE IF EXISTS {name}")) + await conn.execute(text(f"CREATE DATABASE {name}")) + await admin.dispose() + engines = [] + for name in ("zp_wire_ts", "zp_wire_py"): + engine = create_async_engine(f"postgresql+asyncpg://{base.split('://', 1)[1]}/{name}") + async with engine.begin() as conn: + for statement in DDL: + await conn.execute(text(statement)) + engines.append(engine) + return engines[0], engines[1] + + ts_engine, py_engine = runner.run(setup()) + yield WireDB(runner=runner, ts_url=f"{base}/zp_wire_ts", py_engine=py_engine, ts_engine=ts_engine) + runner.run(ts_engine.dispose()) + runner.run(py_engine.dispose()) diff --git a/tests/wire/mutators.py b/tests/wire/mutators.py new file mode 100644 index 0000000..3b8aa1f --- /dev/null +++ b/tests/wire/mutators.py @@ -0,0 +1,107 @@ +"""The Python twin of `harness/mutate.ts`: the same mutators, handler and failing provider.""" + +from collections.abc import Awaitable, Callable +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncConnection + +from zero_python.protocol.application_error import ApplicationError +from zero_python.protocol.mutation import CustomMutation, MutationResponse +from zero_python.server.process_mutations import ( + Database, + TransactFn, + TransactionProviderHooks, + TransactionProviderInput, +) +from zero_python.server.sqlalchemy import SQLAlchemyDatabase +from zero_python.server.transaction import ServerTransaction + +type Tx = ServerTransaction[AsyncConnection] + +PUT = """INSERT INTO item (id, value) VALUES ($1, $2) + ON CONFLICT (id) DO UPDATE SET value = item.value || '+' || excluded.value""" + + +def _js_string(value: Any, default: str) -> str: + if value is None: + return default + if isinstance(value, bool): + return "true" if value else "false" + from zero_python.shared.js import js_string + + return js_string(value) if not isinstance(value, dict) else "[object Object]" + + +async def put(tx: Tx, args: dict[str, Any]) -> None: + await tx.db_transaction.query(PUT, (_js_string(args.get("id"), "x"), _js_string(args.get("value"), ""))) + + +async def app_error(tx: Tx, args: dict[str, Any]) -> None: + await put(tx, args) + raise ApplicationError(_js_string(args.get("message"), "refused"), details=args.get("details")) + + +async def error(tx: Tx, args: dict[str, Any]) -> None: + await put(tx, args) + raise Exception(_js_string(args.get("message"), "broke")) + + +async def type_error(tx: Tx, args: dict[str, Any]) -> None: + await put(tx, args) + raise TypeError(_js_string(args.get("message"), "bad type")) + + +async def db_error(tx: Tx, _args: dict[str, Any]) -> None: + await tx.db_transaction.query("SELECT * FROM missing_table") + + +MUTATORS: dict[str, Callable[[Tx, dict[str, Any]], Awaitable[None]]] = { + "item.put": put, + "item.appError": app_error, + "item.error": error, + "item.typeError": type_error, + "item.dbError": db_error, +} + + +def _args(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +async def handler(transact: TransactFn[Tx], mutation: CustomMutation) -> MutationResponse: + args = _args(mutation.args[0] if mutation.args else None) + if mutation.name == "pre.appError": + raise ApplicationError(_js_string(args.get("message"), "pre"), details=args.get("details")) + if mutation.name == "pre.error": + raise Exception(_js_string(args.get("message"), "pre")) + + async def run(tx: Tx, name: str, mutator_args: Any) -> None: + mutator = MUTATORS.get(name.replace("post.", "item.", 1) if name.startswith("post.") else name) + if mutator is None: + raise Exception(f"unknown mutator {name}") + await mutator(tx, _args(mutator_args)) + + result = await transact(run) + if mutation.name.startswith("post."): + raise Exception("after commit") + return result + + +class FailingForCfail: + """Fails to open any transaction for client `cfail`, as the TypeScript twin does.""" + + def __init__(self, db: SQLAlchemyDatabase) -> None: + self.db = db + + async def transaction( + self, + callback: Callable[[Tx, TransactionProviderHooks], Awaitable[Any]], + transaction_input: TransactionProviderInput | None = None, + ) -> Any: + if transaction_input is not None and transaction_input.client_id == "cfail": + raise Exception("cannot open") + return await self.db.transaction(callback, transaction_input) + + +def provider(db: SQLAlchemyDatabase) -> Database[Tx]: + return FailingForCfail(db) diff --git a/tests/wire/test_mutate.py b/tests/wire/test_mutate.py new file mode 100644 index 0000000..217b9ef --- /dev/null +++ b/tests/wire/test_mutate.py @@ -0,0 +1,203 @@ +"""The wire tier for `/mutate`: the same pushes through upstream's handler and Python's, then a diff of +the responses and of every row either side wrote.""" + +from typing import Any + +from hypothesis import example, given +from hypothesis import strategies as st + +from tests.harness import Harness +from tests.strategies import as_js, cleanup_args, corrupted, json_values, mutation_ids +from tests.wire.conftest import WireDB +from tests.wire.mutators import handler, provider +from zero_python.server.process_mutations import handle_mutate_request +from zero_python.server.sqlalchemy import SQLAlchemyDatabase + +NAMES = [ + "item.put", "item.put", "item.put", "item.appError", "item.error", "item.typeError", "item.dbError", + "pre.appError", "pre.error", "post.put", "nope", +] # fmt: skip + +texts = st.sampled_from(["a", "b", "c", "é"]) +mutator_args = st.fixed_dictionaries( + {"id": texts, "value": texts}, + optional={"message": st.sampled_from(["no", "nope", ""]), "details": json_values}, +) + + +VALID_QUERY = {"schema": "zero_0", "appID": "zero"} +BAD_QUERIES = [{"schema": "zero_0"}, {"appID": "zero"}, {"schema": "zero_0", "appID": "zero", "x": "y"}] + + +def _weighted(draw: st.DrawFn, choices: list[tuple[int, Any]]) -> Any: + total = sum(w for w, _ in choices) + roll = draw(st.integers(0, total - 1)) + for weight, value in choices: + if roll < weight: + return value + roll -= weight + raise AssertionError + + +@st.composite +def scenarios(draw: st.DrawFn) -> list[tuple[Any, dict[str, str], str | None]]: + """Pushes as a client group sends them: per-client IDs in sequence, with the odd duplicate, gap or + malformed request, so every branch of the state machine runs and most mutations get through.""" + next_id: dict[str, int] = {} + pushes = [] + for _ in range(draw(st.integers(1, 4))): + mutations = [] + for _ in range(draw(st.integers(0, 4))): + kind = _weighted(draw, [(85, "custom"), (10, "cleanup"), (2, "crud")]) + client = _weighted(draw, [(45, "c1"), (45, "c2"), (3, "cfail")]) + if kind == "cleanup": + # Cleanup args name their own clients; `cfail` makes the cleanup's transaction fail. + args = draw(st.lists(cleanup_args() | json_values, max_size=1)) + if args and isinstance(args[0], dict) and "clientID" in args[0] and draw(st.integers(0, 9)) == 0: + args = [{**args[0], "clientID": "cfail"}] + mutations.append( + { + "type": "custom", + "id": 0, + "clientID": client, + "name": "_zero_cleanupResults", + "args": args, + "timestamp": 1, + } + ) + continue + expected = next_id.get(client, 1) + mutation_id = _weighted( + draw, [(85, expected), (7, max(expected - 1, 0)), (4, expected + 1), (4, draw(mutation_ids))] + ) + next_id[client] = max(expected, mutation_id + 1) + if kind == "crud": + mutations.append( + { + "type": "crud", + "id": mutation_id, + "clientID": client, + "name": "_zero_crud", + "args": [{"ops": []}], + "timestamp": 1, + } + ) + continue + mutations.append( + { + "type": "custom", + "id": mutation_id, + "clientID": client, + "name": draw(st.sampled_from(NAMES)), + "args": draw(st.lists(mutator_args, max_size=1)) + if draw(st.integers(0, 9)) == 0 + else [draw(mutator_args)], + "timestamp": 1, + } + ) + body = { + "clientGroupID": "cg1", + "mutations": mutations, + "pushVersion": _weighted(draw, [(95, 1), (5, 2)]), + "timestamp": 1, + "requestID": "r", + } + if draw(st.integers(0, 19)) == 0: + body = draw(corrupted(st.just(body))) + query = VALID_QUERY if draw(st.integers(0, 19)) else draw(st.sampled_from(BAD_QUERIES)) + pushes.append((body, query, draw(st.sampled_from([None, "u1"])))) + return pushes + + +def comparable(response: dict[str, Any]) -> dict[str, Any]: + """A response with parse-failure messages cut to their prefix. + + Upstream's message embeds valita's description of the first bad field; Python's embeds Pydantic's. + zero-cache only logs the message, so the prefix and the rest of the body are what must agree. + """ + if response.get("reason") == "parse": + response = {**response, "message": response["message"].split(":", 1)[0]} + return as_js(response) + + +def _m(mid: int, name: str = "item.put", /, *, client: str = "c1", **args: Any) -> dict[str, Any]: + return {"type": "custom", "id": mid, "clientID": client, "name": name, "args": [args], "timestamp": 1} + + +def _push(*mutations: dict[str, Any], version: int = 1) -> dict[str, Any]: + return { + "clientGroupID": "cg1", + "mutations": list(mutations), + "pushVersion": version, + "timestamp": 1, + "requestID": "r", + } + + +def _cleanup(arg: Any) -> dict[str, Any]: + return {"type": "custom", "id": 0, "clientID": "c1", "name": "_zero_cleanupResults", "args": [arg], "timestamp": 1} + + +def _scenario(*bodies: dict[str, Any]) -> list[tuple[Any, dict[str, str], str | None]]: + return [(body, VALID_QUERY, "u1") for body in bodies] + + +# One per branch of upstream's process-mutations.ts, so each runs whatever Hypothesis draws. +BRANCHES = { + "ok": _scenario(_push(_m(1, id="a", value="x"), _m(2, id="a", value="y"))), + "app error": _scenario(_push(_m(1, "item.appError", id="a", value="x", details={"k": [1]}), _m(2))), + "plain error, retried": _scenario(_push(_m(1, "item.error", message=""), _m(2, "item.typeError"))), + "database error": _scenario(_push(_m(1, "item.dbError"))), + "unknown mutator": _scenario(_push(_m(1, "nope"))), + "pre-transaction error": _scenario(_push(_m(1, "pre.appError", details=0), _m(2, "pre.error"))), + "post-commit error": _scenario(_push(_m(1, "post.put", id="b", value="z"))), + "already processed": _scenario(_push(_m(1)), _push(_m(1), _m(2))), + "out of order": _scenario(_push(_m(1), _m(3), _m(4))), + "unsupported push version": _scenario(_push(_m(1), version=2)), + "crud mutation": _scenario(_push(_m(1), {**_m(2), "type": "crud", "name": "_zero_crud", "args": [{"ops": []}]})), + "open failure": _scenario(_push(_m(1, client="cfail"), _m(1, client="c2"))), + "cleanup single and legacy": _scenario( + _push(_m(1, "item.appError"), _m(2, "item.appError"), _m(3, "item.appError")), + _push(_cleanup({"type": "single", "clientGroupID": "cg1", "clientID": "c1", "upToMutationID": 1})), + _push(_cleanup({"clientGroupID": "cg1", "clientID": "c1", "upToMutationID": 2})), + ), + "cleanup bulk": _scenario( + _push(_m(1, "item.appError"), _m(1, "item.appError", client="c2")), + _push(_cleanup({"type": "bulk", "clientGroupID": "cg1", "clientIDs": ["c2", "c1"]})), + ), + "cleanup invalid and failing": _scenario( + _push(_cleanup({"type": "bulk", "clientGroupID": "cg1", "clientIDs": []}), _m(1)), + _push(_cleanup({"clientGroupID": "cg1", "clientID": "cfail", "upToMutationID": 2}), _m(2)), + ), +} + + +def _with_branches(test: Any) -> Any: + for scenario in BRANCHES.values(): + test = example(scenario)(test) + return test + + +@_with_branches +@given(scenarios()) +def test_pushes_match_upstream(harness: Harness, wire_db: WireDB, pushes: list[Any]) -> None: + wire_db.reset() + py_db = provider(SQLAlchemyDatabase(wire_db.py_engine)) + for body, query, user_id in pushes: + expected = harness.call("mutate", url=wire_db.ts_url, query=query, body=body, userID=user_id) + actual = wire_db.run( + handle_mutate_request(db_provider=py_db, handler=handler, query=query, body=body, user_id=user_id) + ).to_json() + assert comparable(actual) == comparable(expected) + assert as_js(wire_db.dump(wire_db.py_engine)) == as_js(wire_db.dump(wire_db.ts_engine)) + + +def test_transaction_exposes_the_connection(wire_db: WireDB) -> None: + async def check() -> None: + async def callback(tx: Any, _hooks: Any) -> Any: + return tx.db_transaction.wrapped_transaction + + connection = await SQLAlchemyDatabase(wire_db.py_engine).transaction(callback) + assert connection.__class__.__name__ == "AsyncConnection" + + wire_db.run(check()) diff --git a/uv.lock b/uv.lock index 04c4aab..6984025 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "asyncpg" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/06/d5f956db9c936c90cd3289cf948a86c3efc9849e26354356c23da29f6a2d/asyncpg-0.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7cb31f7a8472ddc6b6f5c9da1290e901d5c77c8441c7213bd13b13ef6fe6359c", size = 681566, upload-time = "2026-10-06T20:30:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/ea55f3b26fd40ec90e5b6d6c53b9ff52633cf6b87a468d9c033a727832f4/asyncpg-0.32.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:643d8d6e955a355045dddfe827d74f4f0d1dc4a18e06963a08260af838fbf093", size = 704359, upload-time = "2026-10-06T20:30:54.608Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/a3704e8675d37b168f3584661fc9f64f3021659c9b94e51cf9ab957b2bc5/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:14ff79ca2574182ce258159c48978a086f9026fc121d935017b5d10c64fa3c72", size = 3707008, upload-time = "2026-10-06T20:30:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/4fd8d1155b3d7a32a2c241dcb9c5d9e9bd74a59ae71ed25ef8ddb8e038e1/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:54851411bee2aa51a30d0911524201fbb05f82cc0f7c248b140203db637c723d", size = 3810163, upload-time = "2026-10-06T20:30:58.114Z" }, + { url = "https://files.pythonhosted.org/packages/c1/25/5b0992d45661e1488aba775cf17a2e6c82c7d1d7e10acc71efd394760a00/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8592f0ed9c315b2117dbdc707cf3292f09a89d5b07661016a84dd881326965cf", size = 3600446, upload-time = "2026-10-06T20:30:59.946Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/1c82c6feacec813423401b5aef1a43baea951694157f4d405b2d14e80e6d/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dbe0982cb3ded878de0867dfaeae3116faf471d484ea28b3e3da942f01fb778", size = 3764563, upload-time = "2026-10-06T20:31:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/84/f5/5a3796088f0c3f7d22aaf7c48536f40b27e44b7c9603d4d7abfeca2ed97e/asyncpg-0.32.0-cp312-cp312-win32.whl", hash = "sha256:fbe1f8c788fb5df18ea8a5432dfa2473fd8f7f088025fb83d089a7c7b37e37b0", size = 551810, upload-time = "2026-10-06T20:31:03.248Z" }, + { url = "https://files.pythonhosted.org/packages/af/42/f4d333a3f67b0e7cf58ea855f9d5d9104ce38c21f2a2f22bf7dce524428c/asyncpg-0.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd7157a86817730c3239bc687abf8186a471525d695e225c187b9a523a808a98", size = 626763, upload-time = "2026-10-06T20:31:04.927Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9d82e16e1d0b4e2a639a2db649d4b444b8a479cd52553a9c36ba0d6320a8/asyncpg-0.32.0-cp312-cp312-win_arm64.whl", hash = "sha256:9509e21fc526f1fc27cf80ad9f9b8dde3f3e21935d46be66d649635321d3407c", size = 577288, upload-time = "2026-10-06T20:31:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" }, + { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" }, + { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" }, + { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" }, + { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" }, + { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" }, + { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" }, + { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" }, + { url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" }, + { url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -119,6 +181,73 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + [[package]] name = "hypothesis" version = "6.169.1" @@ -429,6 +558,83 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[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/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.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "ty" version = "0.0.87" @@ -483,6 +689,12 @@ dependencies = [ { name = "pydantic" }, ] +[package.optional-dependencies] +sqlalchemy = [ + { name = "asyncpg" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] + [package.dev-dependencies] dev = [ { name = "hypothesis" }, @@ -491,10 +703,16 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, { name = "ty" }, + { name = "zero-python", extra = ["sqlalchemy"] }, ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.14" }] +requires-dist = [ + { name = "asyncpg", marker = "extra == 'sqlalchemy'", specifier = ">=0.32" }, + { name = "pydantic", specifier = ">=2.14" }, + { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'sqlalchemy'", specifier = ">=2.1" }, +] +provides-extras = ["sqlalchemy"] [package.metadata.requires-dev] dev = [ @@ -504,4 +722,5 @@ dev = [ { name = "pytest-cov", specifier = ">=7.1" }, { name = "ruff", specifier = ">=0.17" }, { name = "ty", specifier = ">=0.0.87" }, + { name = "zero-python", extras = ["sqlalchemy"] }, ]