From f2e5c22b3043b2be4b752cb067550ab014046eac Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Wed, 14 Oct 2026 13:06:02 -0700 Subject: [PATCH 1/3] feat: mutate endpoint over a SQLAlchemy transaction provider handle_mutate_request ports process-mutations.ts: each mutation runs in the transaction that bumps its client's lastMutationID, a failed mutator is retried without the mutator to record its error, and cleanup mutations prune results outside the sequence. The wire tier sends the same pushes through the published handleMutateRequest (postgres.js adapter) and through Python, each against its own database, and diffs the responses and every row in zero_0.clients, zero_0.mutations and the app table. Explicit examples pin every branch: ok, app error, already processed, out of order, unsupported push version, open failure, cleanup bulk and single. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 + .gitignore | 1 + harness/mutate.ts | 98 +++++ harness/ops.ts | 3 + harness/package-lock.json | 3 +- harness/package.json | 3 +- harness/runner.ts | 4 + pyproject.toml | 8 + src/zero_python/server/__init__.py | 1 + src/zero_python/server/process_mutations.py | 397 ++++++++++++++++++++ src/zero_python/server/sqlalchemy.py | 113 ++++++ src/zero_python/server/transaction.py | 30 ++ src/zero_python/shared/error.py | 24 +- src/zero_python/shared/js.py | 30 ++ tests/harness.py | 4 + tests/postgres.py | 52 +++ tests/unit/test_mutate.py | 43 +++ tests/unit/test_shared.py | 20 +- tests/wire/conftest.py | 94 +++++ tests/wire/mutators.py | 107 ++++++ tests/wire/test_mutate.py | 203 ++++++++++ uv.lock | 221 ++++++++++- 22 files changed, 1460 insertions(+), 9 deletions(-) create mode 100644 harness/mutate.ts create mode 100644 src/zero_python/server/__init__.py create mode 100644 src/zero_python/server/process_mutations.py create mode 100644 src/zero_python/server/sqlalchemy.py create mode 100644 src/zero_python/server/transaction.py create mode 100644 tests/postgres.py create mode 100644 tests/unit/test_mutate.py create mode 100644 tests/wire/conftest.py create mode 100644 tests/wire/mutators.py create mode 100644 tests/wire/test_mutate.py 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..8d0a87d 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}); 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..5e06d18 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,32 @@ 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(v)}" for k, v in value.items()) + return "{" + ",".join(members) + "}" + case _: + raise TypeError(f"not a JSON value: {type(value).__name__}") diff --git a/tests/harness.py b/tests/harness.py index 9196209..62e9e31 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, ) @@ -39,3 +42,4 @@ def close(self) -> None: if self._process.stdin is not None: self._process.stdin.close() self._process.wait(timeout=10) + 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..c9bd3ff 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,11 @@ 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}' + 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..c36bec0 --- /dev/null +++ b/tests/wire/conftest.py @@ -0,0 +1,94 @@ +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"::jsonb AS result ' + 'FROM zero_0."mutations" ORDER BY 1, 2, 3', + "item": "SELECT * FROM item ORDER BY id", + } + return { + name: [dict(row) for row in (await conn.execute(text(sql))).mappings()] + for name, sql in queries.items() + } + + 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"] }, ] From c97952b7faaf17188180f200ca8c3b43b1842689 Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Wed, 14 Oct 2026 13:09:23 -0700 Subject: [PATCH 2/3] fix: store mutation results as the exact text upstream writes JSON.stringify puts integer-like keys first in numeric order, so a details object from the client could be stored with its keys in a different order than upstream stores it. The wire tier now diffs the stored text, not the parsed value. Co-Authored-By: Claude Opus 5.5 --- src/zero_python/shared/js.py | 12 +++++++++++- tests/unit/test_shared.py | 1 + tests/wire/conftest.py | 6 ++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/zero_python/shared/js.py b/src/zero_python/shared/js.py index 5e06d18..7aafc5b 100644 --- a/src/zero_python/shared/js.py +++ b/src/zero_python/shared/js.py @@ -103,7 +103,17 @@ def js_stringify(value: object) -> str: case list() | tuple(): return "[" + ",".join(js_stringify(item) for item in value) + "]" case dict(): - members = (f"{json.dumps(k, ensure_ascii=False)}:{js_stringify(v)}" for k, v in value.items()) + 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/unit/test_shared.py b/tests/unit/test_shared.py index c9bd3ff..b019b4a 100644 --- a/tests/unit/test_shared.py +++ b/tests/unit/test_shared.py @@ -128,5 +128,6 @@ 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 index c36bec0..ef1a114 100644 --- a/tests/wire/conftest.py +++ b/tests/wire/conftest.py @@ -54,14 +54,16 @@ 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"::jsonb AS result ' + "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", } - return { + 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()) From ed3daf47e133689b8f389ab25ea079af85cef024 Mon Sep 17 00:00:00 2001 From: Jonas Daniels Date: Wed, 14 Oct 2026 13:11:23 -0700 Subject: [PATCH 3/3] fix: let the harness exit when its input closes Co-Authored-By: Claude Opus 5.5 --- harness/runner.ts | 2 ++ tests/harness.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/harness/runner.ts b/harness/runner.ts index 8d0a87d..eb8e0cb 100644 --- a/harness/runner.ts +++ b/harness/runner.ts @@ -20,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/tests/harness.py b/tests/harness.py index 62e9e31..5db2b9b 100644 --- a/tests/harness.py +++ b/tests/harness.py @@ -41,5 +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()