diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d2c68ccb..559ba0c2 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -30,16 +30,3 @@ jobs: - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: token: ${{ steps.app.outputs.token }} - - name: Enable checked auto-merge for release PRs - shell: bash - env: - GH_TOKEN: ${{ steps.app.outputs.token }} - GH_REPO: ${{ github.repository }} - # Looking up the branch also handles retries when Release Please leaves the PR unchanged. - run: | - gh pr list --base main --head release-please--branches--main--components--volcano-sdk --state open \ - --json number,author,isCrossRepository,isDraft,headRefOid \ - --jq '.[] | select(.author.login == "app/kong-volcano-app" and .isCrossRepository == false and .isDraft == false) | [.number, .headRefOid] | @tsv' | - while IFS=$'\t' read -r number head; do - gh pr merge "$number" --auto --squash --match-head-commit "$head" - done diff --git a/AGENTS.md b/AGENTS.md index 7336ffd3..e82e36b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,6 @@ behalf. - Keep reviewer and repository-administration credentials outside ordinary automation. -- Preserve shared behavioral scenarios and coordinate contract changes with - `Kong/volcano-hosting` and the other SDKs. +- Keep native unit, type, build, and package checks here. Hosting owns behavioral + acceptance scenarios and bindings; coordinate changes with `Kong/volcano-hosting`. - Keep maintainer guidance under `maintainers/`; `docs/` is published. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a5ade8a..f200ab4f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,7 @@ Classify the impact in the PR before changing the contract: | Change | Required updates | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Public facade or SDK-facing wire contract | Audit all three SDKs; update each affected facade, native tests, and public language examples. Regenerate internal clients when their wire snapshot changes. | -| Shared behavior | Update the canonical requirement ID and Gherkin scenario in Hosting, every affected language binding, checked-in feature copies, native tests, and equivalent public examples. | +| Shared behavior | Update the canonical requirement ID and Gherkin scenario in Hosting, every affected Hosting-owned language binding, native tests, and equivalent public examples. | | Native behavior | Add native regression coverage and document language-specific behavior. Update shared scenarios only if the shared behavior changes. | | Public examples | Update equivalent examples in every affected language and verify their public API calls. | @@ -57,30 +57,21 @@ PRs. Keep each PR focused and use Conventional Commits. Obtain clean code and security reviews and passing required checks on the final commit before merge. Hosting changes also require human approval. -### Roll out shared scenarios - -Before merging SDK code, prove it remains compatible with the currently deployed -Hosting contract. Staging Gherkin does not keep runtime code dormant, and the -existing release automation can publish a main-derived package. If new server -support is required, first land a backward-compatible Hosting prerequisite or -keep the SDK PR in draft until an explicitly reviewed release/rollout plan is in -place. Do not merge an incompatible implementation merely because its scenario -is staged. - -1. Change the canonical scenario in Hosting once. Copy its bytes into each SDK - and implement its native binding. -2. Stage new scenarios under `features/staged` while Hosting main still uses the - older contract. Do not activate scenarios ahead of Hosting. -3. Merge the required SDK changes before validating and merging the coordinated - Hosting PR. Record the Hosting and SDK revisions used by acceptance. -4. After Hosting merges, promote those unchanged files into `features/contract` - and run the default binding-discovery checks. Do not keep duplicate active - and staged copies. - -Hosting CI checks out each SDK's latest `main` and records the actual tested -SHAs. Do not introduce a checked-in pin manifest or assume a rerun uses the same -SDK revisions. Generate and verify each SDK against its own OpenAPI snapshot; -compatibility with the server is established by integration tests. +### Roll out shared behavior + +Hosting owns the canonical scenarios and all language bindings under +`tests/sdk-contract`. Its Staging Validation builds each SDK's latest `main`, +installs the distribution in a fresh environment, and exercises public behavior. +Do not copy features or acceptance runners into this repository. + +Land compatible server support before an SDK version needs it in production. +Then merge the SDK implementation and native tests. Update the Hosting scenarios +and bindings in the coordinated PR, and validate those SDK main revisions in +Staging Validation. Record the Hosting and SDK commits from that run. + +A maintainer manually merges the Release Please version PR; the existing release +and trusted-publisher workflows publish automatically. Hosting acceptance is +independent and does not gate SDK publication. When the wire contract changes, first build Hosting's public bundle with `scripts/ci/openapi-bundle.sh ` and update the affected SDK's @@ -89,20 +80,17 @@ check. The generator reads the vendored snapshot; it does not update that snapshot from Hosting. Do not use snapshot equality as a server compatibility gate. -From a Hosting checkout, verify shared tooling and copies before review: +From a Hosting checkout, verify shared tooling before review: ```shell npm ci --prefix tests/sdk-contract --ignore-scripts npm test --prefix tests/sdk-contract -bash scripts/ci/run-sdk-contract-tests_test.sh -bash scripts/ci/run-sdk-contract-tests.sh --validate-features-only \ - /path/to/volcano-sdk-js /path/to/volcano-sdk-python /path/to/volcano-sdk-ruby +go test ./scripts/ci ``` -Without `--validate-features-only`, the runner creates and deletes fixtures. -Use an approved disposable environment for live runs; staging and production -require explicit authorization. Ordinary Cloud E2E remains post-merge. Do not -infer live acceptance from tooling checks or a nonblocking staging result. +Use the installed-package runner's `inspect` mode to verify bindings without +provisioning. Staging Validation runs the live suite and requires cleanup. Record +actual live results separately from discovery or package checks. ### Documentation and release boundaries diff --git a/README.md b/README.md index 1f1d115b..8380be4f 100644 --- a/README.md +++ b/README.md @@ -1021,20 +1021,14 @@ uv sync --locked uv run --locked poe quality ``` -Live contract scenarios require an isolated fixture produced by -`volcano-hosting/tests/sdk-contract/support/fixture.mjs`: - -```shell -VOLCANO_SDK_CONTRACT_FIXTURE=/absolute/path/to/fixture.json \ - uv run behave features/contract --junit --junit-directory reports/behave -``` - -The fixture must be an absolute path to a mode-`0600` JSON file. +Hosting owns and runs the shared black-box acceptance suite during Staging Validation. +It builds this repository's latest `main` and installs the wheel in a fresh environment. +See [Hosting's testing guide](https://github.com/Kong/volcano-hosting/blob/main/docs/internal/guides/sdk-contract-testing.md). ## Release to PyPI Release Please creates a version and changelog PR from releasable commits. -After its required checks pass, the existing auto-merge policy merges the PR. +A maintainer manually merges the version PR after its required checks pass. The Volcano GitHub App creates the stable GitHub release, which automatically starts `publish.yml`. The workflow validates the tag, main ancestry, and package identity; runs CI; builds and smoke-tests the wheel and source distribution; diff --git a/features/broadcast_pause.py b/features/broadcast_pause.py deleted file mode 100644 index c0a149d7..00000000 --- a/features/broadcast_pause.py +++ /dev/null @@ -1,48 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from contract_support import ContractWorld - - from volcano_sdk.realtime import Channel - - -async def _publish_and_receive( - publisher: Channel, received: asyncio.Queue[object], message: dict[str, str] -) -> object: - async with asyncio.timeout(10): - await publisher.send(message) - while True: - delivered = await received.get() - if delivered == message: - return delivered - - -async def verify_broadcast_pause(world: ContractWorld) -> object: - subscriber, publisher = world.subscriber, world.publisher - assert subscriber is not None - assert publisher is not None - received: asyncio.Queue[object] = asyncio.Queue() - delivered: list[object] = [] - - def on_message(message: object) -> None: - delivered.append(message) - received.put_nowait(message) - - _ = subscriber.on("message", on_message) - baseline = { - **world.realtime_message, - "value": world.realtime_message["value"] + "-baseline", - } - _ = await _publish_and_receive(publisher, received, baseline) - await subscriber.unsubscribe() - delivered.clear() - await publisher.send( - {**world.realtime_message, "value": world.realtime_message["value"] + "-paused"} - ) - await asyncio.sleep(1) - assert not delivered, "subscriber delivered a message while paused" - await subscriber.subscribe() - return await _publish_and_receive(publisher, received, world.realtime_message) diff --git a/features/contract/auth-profile-refresh.feature b/features/contract/auth-profile-refresh.feature deleted file mode 100644 index 5041afa3..00000000 --- a/features/contract/auth-profile-refresh.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: SDK profile authentication recovery - - @auth @SDK-AUTH-007 - Scenario: A rejected profile read refreshes the session and caches the same user - Given an authenticated client - And the client replaces its access token with a rejected token - When the client loads its server-validated profile - Then the SDK operation succeeds - And the returned and cached profiles belong to the contract user - And the profile read replaces the rejected token for the same user diff --git a/features/contract/auth-request-recovery.feature b/features/contract/auth-request-recovery.feature deleted file mode 100644 index cc22cd4c..00000000 --- a/features/contract/auth-request-recovery.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: SDK authenticated request recovery - - @auth @SDK-AUTH-009 - Scenario: A rejected session list refreshes the captured session once - Given an authenticated client - And the client replaces its access token with a rejected token - When the client lists its server sessions - Then the SDK operation succeeds - And the session list contains the current session for the contract user - And the session list replaces the rejected token for the same user diff --git a/features/contract/auth-token-bootstrap.feature b/features/contract/auth-token-bootstrap.feature deleted file mode 100644 index f1f11f41..00000000 --- a/features/contract/auth-token-bootstrap.feature +++ /dev/null @@ -1,31 +0,0 @@ -Feature: SDK token-only authentication - - @auth @SDK-AUTH-008 - Scenario: A client uses a supplied access token without inventing session fields - Given an authenticated client - When a fresh client tries to refresh a supplied profile without a session identifier - Then the SDK operation fails - When a fresh client starts with only the current access token - Then the SDK operation succeeds - And the token-only session has no cached user - And the session retains only the supplied access token - When the client loads its server-validated profile - Then the SDK operation succeeds - And the returned and cached profiles belong to the contract user - And the session retains only the supplied access token - When the client signs out - Then the SDK operation succeeds - And the current session is empty - When a fresh client loads a profile with the signed-out access token - Then the SDK operation fails with an authentication error - When a fresh client starts with a rejected access token - And the client loads its server-validated profile - Then the SDK operation fails with an authentication error - And the session retains only the supplied access token - And the token-only session has no cached user - When the client refreshes the current session - Then the SDK operation fails - And the session retains only the supplied access token - When the client signs out - Then the SDK operation succeeds - And the current session is empty diff --git a/features/contract/auth.feature b/features/contract/auth.feature deleted file mode 100644 index 615f3d57..00000000 --- a/features/contract/auth.feature +++ /dev/null @@ -1,56 +0,0 @@ -Feature: SDK authentication contract - - @auth @SDK-AUTH-001 - Scenario: Successful password sign-in creates a usable session - Given the confirmed contract user - When the client signs in with the contract user's credentials - Then the SDK operation succeeds - And the current session belongs to the contract user - And the current session exposes access and refresh tokens - - @auth @SDK-AUTH-002 - Scenario: The auth facade returns the current session - Given the confirmed contract user - When the client signs in with the contract user's credentials - And the client reads the current session - Then the SDK operation succeeds - And the current session belongs to the contract user - And the current session exposes access and refresh tokens - - @auth @SDK-AUTH-003 - Scenario: A client adopts a supplied session locally - Given the confirmed contract user - When the client signs in with the contract user's credentials - And a fresh client adopts the current session - Then the SDK operation succeeds - And the current session belongs to the contract user - And the current session exposes access and refresh tokens - - @auth @SDK-AUTH-004 - Scenario: A client refreshes its current session - Given the confirmed contract user - When the client signs in with the contract user's credentials - And the client refreshes the current session - Then the SDK operation succeeds - And the refreshed session becomes current - And the current session belongs to the contract user - And the current session exposes access and refresh tokens - - @auth @SDK-AUTH-005 - Scenario: A client signs out its current session - Given the confirmed contract user - When the client signs in with the contract user's credentials - And the client signs out - Then the SDK operation succeeds - And the current session is empty - When a fresh client tries to refresh the signed-out session - Then the SDK operation fails with an authentication error - And the current session is empty - - @auth @SDK-AUTH-006 - Scenario: An auth-state listener observes sign-in - Given the confirmed contract user - And the client listens for auth state changes - When the client signs in with the contract user's credentials - Then the SDK operation succeeds - And the auth-state listener observes the signed-in contract user diff --git a/features/contract/database-delete.feature b/features/contract/database-delete.feature deleted file mode 100644 index dd3384b9..00000000 --- a/features/contract/database-delete.feature +++ /dev/null @@ -1,16 +0,0 @@ -Feature: SDK database delete contract - - @database @SDK-DB-004 - Scenario: An equality-filtered delete returns the removed row - Given an authenticated client - When the client deletes its contract row - Then the SDK operation succeeds - And exactly the deleted contract row is returned - - @database @SDK-DB-006 - Scenario: An equality-filtered delete with no match returns an empty list - Given an authenticated client - When the client deletes a missing contract row - Then the SDK operation succeeds - And the mutation returns an empty row list - And the existing contract row is unchanged diff --git a/features/contract/database-insert.feature b/features/contract/database-insert.feature deleted file mode 100644 index e0e413da..00000000 --- a/features/contract/database-insert.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK database insert contract - - @database @SDK-DB-002 - Scenario: Insert returns the created row - Given an authenticated client - When the client inserts its contract row - Then the SDK operation succeeds - And exactly the inserted contract row is returned diff --git a/features/contract/database-queries.feature b/features/contract/database-queries.feature deleted file mode 100644 index 00e1340c..00000000 --- a/features/contract/database-queries.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: SDK database query semantics - - @database @SDK-DB-008 - Scenario: Projected member rows use ordered pagination - Given an authenticated client - When the client selects a projected page of query fixture members - Then the SDK operation succeeds - And the projected page contains only beta and gamma in that order - - @database @SDK-DB-009 - Scenario: Numeric comparison filters preserve strict and inclusive boundaries - Given an authenticated client - When the client selects query fixture rows with each comparison filter - Then the SDK operation succeeds - And each comparison returns exactly the matching query fixture rows - - @database @SDK-DB-010 - Scenario: Pattern filters distinguish case sensitivity - Given an authenticated client - When the client selects query fixture rows with case-sensitive and insensitive patterns - Then the SDK operation succeeds - And each pattern returns exactly the matching query fixture rows - - @database @SDK-DB-011 - Scenario: Identity filters preserve null and boolean values - Given an authenticated client - When the client selects query fixture rows with null and boolean filters - Then the SDK operation succeeds - And each identity filter returns exactly the matching query fixture rows diff --git a/features/contract/database-refresh.feature b/features/contract/database-refresh.feature deleted file mode 100644 index 5b20a468..00000000 --- a/features/contract/database-refresh.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: SDK database authentication recovery - - @database @SDK-DB-007 - Scenario: A rejected database read refreshes the session and retries - Given an authenticated client - And the client replaces its access token with a rejected token - When the client selects the contract table where "slug" equals the fixture slug - Then the SDK operation succeeds - And exactly the fixture row is returned - And the database read replaces the rejected token for the same user diff --git a/features/contract/database-update.feature b/features/contract/database-update.feature deleted file mode 100644 index d14361ab..00000000 --- a/features/contract/database-update.feature +++ /dev/null @@ -1,16 +0,0 @@ -Feature: SDK database update contract - - @database @SDK-DB-003 - Scenario: An equality-filtered update returns the changed row - Given an authenticated client - When the client updates its contract row - Then the SDK operation succeeds - And exactly the updated contract row is returned - - @database @SDK-DB-005 - Scenario: An equality-filtered update with no match returns an empty list - Given an authenticated client - When the client updates a missing contract row - Then the SDK operation succeeds - And the mutation returns an empty row list - And the existing contract row is unchanged diff --git a/features/contract/database.feature b/features/contract/database.feature deleted file mode 100644 index 1accc448..00000000 --- a/features/contract/database.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK database contract - - @database @SDK-DB-001 - Scenario: Equality filters select the matching row - Given an authenticated client - When the client selects the contract table where "slug" equals the fixture slug - Then the SDK operation succeeds - And exactly the fixture row is returned diff --git a/features/contract/durable.feature b/features/contract/durable.feature deleted file mode 100644 index d1313a3f..00000000 --- a/features/contract/durable.feature +++ /dev/null @@ -1,34 +0,0 @@ -Feature: SDK durable execution contract - - @durable @SDK-DURABLE-001 - Scenario: Starting a durable execution returns a handle, not a result - Given a service-role client - When the client starts the contract durable function - Then the SDK operation succeeds - And the started execution carries its id, function, name, region, and creation time - And the started execution is not terminal and carries no result - - @durable @SDK-DURABLE-002 - Scenario: One execution name starts one execution - Given a service-role client - When the client starts the contract durable function twice under one execution name - Then the SDK operation succeeds - And both starts return the same execution - - @durable @SDK-DURABLE-003 - Scenario: An owner-scoped read follows an execution to its result - Given a service-role client - And a project-owner client - When the client starts the contract durable function - And the owner reads the execution until it is terminal - Then the SDK operation succeeds - And the execution succeeded carrying the function's result - - @durable @SDK-DURABLE-004 - Scenario: An owner-scoped list includes the started execution - Given a service-role client - And a project-owner client - When the client starts the contract durable function - And the owner lists the durable function's executions - Then the SDK operation succeeds - And the listed executions include the started execution diff --git a/features/contract/functions-refresh.feature b/features/contract/functions-refresh.feature deleted file mode 100644 index bda1b215..00000000 --- a/features/contract/functions-refresh.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: SDK function authentication recovery - - @functions @SDK-FUNCTIONS-002 - Scenario: A rejected user credential refreshes before function dispatch - Given an authenticated client - And the client replaces its access token with a rejected token - When the authenticated client invokes the contract function by name - Then the SDK operation succeeds - And the function echoes the payload - And the function invocation replaces the rejected token for the same user diff --git a/features/contract/functions.feature b/features/contract/functions.feature deleted file mode 100644 index 6dc29a9b..00000000 --- a/features/contract/functions.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK function invocation contract - - @functions @SDK-FUNCTIONS-001 - Scenario: A function invoked by name answers from the resolved endpoint - Given a service-role client - When the client invokes the contract function by name - Then the SDK operation succeeds - And the function echoes the payload diff --git a/features/contract/locks-recovery.feature b/features/contract/locks-recovery.feature deleted file mode 100644 index f4d58d32..00000000 --- a/features/contract/locks-recovery.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: SDK lock recovery and lifecycle - - @locks @SDK-LOCKS-002 - Scenario: Caller-owned acquisition can be recovered and renewed - Given a service-role client - When the client recovers the contract lock with caller-owned tokens - Then the SDK operation succeeds - And recovery and renewal preserve the held lease until release - - @locks @SDK-LOCKS-003 - Scenario: Administrative release removes the current lease - Given a service-role client - When the client acquires and force releases the contract lock - Then the SDK operation succeeds - And the force-released lock is available - When the client reacquires the force-released contract lock - Then the SDK operation succeeds - And the replacement owner receives a higher fencing token diff --git a/features/contract/locks.feature b/features/contract/locks.feature deleted file mode 100644 index dceff4a4..00000000 --- a/features/contract/locks.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK lock contract - - @locks @SDK-LOCKS-001 - Scenario: An acquired lock can be released - Given a service-role client - When the client acquires and releases the contract lock - Then the SDK operation succeeds - And the released lease is no longer held diff --git a/features/contract/logs.feature b/features/contract/logs.feature deleted file mode 100644 index a81db1db..00000000 --- a/features/contract/logs.feature +++ /dev/null @@ -1,17 +0,0 @@ -Feature: SDK retained project log contract - - @logs @SDK-LOGS-001 - Scenario: A project token searches and paginates structured function logs - Given a read-only project logs client - When the contract function emits three unique structured log events - And the client searches and paginates those events within 240 seconds - Then the SDK operation succeeds - And all three structured events retain their metadata without duplicates - - @logs @SDK-LOGS-002 - Scenario: A project token counts a matching function log in activity buckets - Given a read-only project logs client - When the contract function emits one unique structured log event - And the client reads matching log activity within 120 seconds - Then the SDK operation succeeds - And activity counts exactly that event in its function and level buckets diff --git a/features/contract/realtime-pause.feature b/features/contract/realtime-pause.feature deleted file mode 100644 index 9b6db55e..00000000 --- a/features/contract/realtime-pause.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK broadcast pause and resume - - @realtime @SDK-REALTIME-002 - Scenario: Broadcast delivery stays silent while paused and resumes with the same handler - Given two authenticated realtime clients - When one client pauses delivery for 1 second and then resumes with the same handler - Then the SDK operation succeeds - And the subscriber receives the contract message within 10 seconds diff --git a/features/contract/realtime-postgres.feature b/features/contract/realtime-postgres.feature deleted file mode 100644 index 53553c03..00000000 --- a/features/contract/realtime-postgres.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK Postgres change delivery contract - - @realtime @SDK-REALTIME-004 - Scenario: Insert and update notifications support automatic rows and lightweight delivery - Given two authenticated realtime clients - When the clients observe an inserted and updated contract row - Then the SDK operation succeeds - And automatic and lightweight notifications retain metadata and row identity diff --git a/features/contract/realtime-presence.feature b/features/contract/realtime-presence.feature deleted file mode 100644 index 17dc2b3c..00000000 --- a/features/contract/realtime-presence.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK presence membership contract - - @realtime @SDK-REALTIME-003 - Scenario: The original presence handler observes another connection joining and leaving - Given two authenticated realtime clients - When one presence client joins and leaves while the other remains subscribed - Then the SDK operation succeeds - And both rosters identify the contract user and the original handler observes membership changes diff --git a/features/contract/realtime.feature b/features/contract/realtime.feature deleted file mode 100644 index dac470bd..00000000 --- a/features/contract/realtime.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: SDK realtime contract - - @realtime @SDK-REALTIME-001 - Scenario: Broadcast reaches another subscribed client - Given two authenticated realtime clients - When one client subscribes and the other publishes the contract message - Then the SDK operation succeeds - And the subscriber receives the contract message within 10 seconds diff --git a/features/contract/storage-lifecycle.feature b/features/contract/storage-lifecycle.feature deleted file mode 100644 index 1c608cc4..00000000 --- a/features/contract/storage-lifecycle.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: SDK storage lifecycle contract - - @storage @SDK-STORAGE-004 - Scenario: Copy preserves the source and move and remove affect only their target - Given an authenticated client - When the client copies, moves, and removes a copy of the contract object - Then the SDK operation succeeds - And the original, copied, and moved bytes equal the uploaded bytes - And moving the copy leaves only the original and moved paths - And removing the moved object leaves the original unchanged diff --git a/features/contract/storage-metadata.feature b/features/contract/storage-metadata.feature deleted file mode 100644 index 1139190d..00000000 --- a/features/contract/storage-metadata.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: SDK storage metadata contract - - @storage @SDK-STORAGE-002 - Scenario: An explicit upload content type is preserved in stored metadata - Given an authenticated client - When the client uploads the contract object as text/plain and reads its stored metadata - Then the SDK operation succeeds - And the uploaded and listed object content types are text/plain - And the downloaded bytes equal the uploaded bytes - And the stored object path equals the contract path diff --git a/features/contract/storage-range.feature b/features/contract/storage-range.feature deleted file mode 100644 index 4fb3dbf1..00000000 --- a/features/contract/storage-range.feature +++ /dev/null @@ -1,9 +0,0 @@ -Feature: SDK storage range contract - - @storage @SDK-STORAGE-003 - Scenario: A byte range download returns exactly the requested bytes - Given an authenticated client - When the client uploads the contract object and downloads bytes 2 through 7 - Then the SDK operation succeeds - And the downloaded bytes equal uploaded bytes 2 through 7 inclusive - And the stored object path equals the contract path diff --git a/features/contract/storage-refresh.feature b/features/contract/storage-refresh.feature deleted file mode 100644 index 349ba1f9..00000000 --- a/features/contract/storage-refresh.feature +++ /dev/null @@ -1,11 +0,0 @@ -Feature: SDK storage authentication recovery - - @storage @SDK-STORAGE-005 - Scenario: A rejected upload refreshes the session and preserves its bytes - Given an authenticated client - And the client replaces its access token with a rejected token - When the client uploads and downloads the contract object - Then the SDK operation succeeds - And the downloaded bytes equal the uploaded bytes - And the stored object path equals the contract path - And the storage operation replaces the rejected token for the same user diff --git a/features/contract/storage-sessions.feature b/features/contract/storage-sessions.feature deleted file mode 100644 index 1495c773..00000000 --- a/features/contract/storage-sessions.feature +++ /dev/null @@ -1,23 +0,0 @@ -Feature: SDK upload sessions and visibility - - @storage @SDK-STORAGE-006 - Scenario: An interrupted multipart upload resumes from server progress - Given an authenticated client - When the client uploads one part and resumes the contract upload - Then the SDK operation succeeds - And upload progress describes exactly the first uploaded part - And the completed multipart object preserves its path, type, and bytes - - @storage @SDK-STORAGE-007 - Scenario: Aborting a partial upload removes its session without publishing an object - Given an authenticated client - When the client uploads one part and aborts the contract upload - Then the SDK operation succeeds - And the aborted session and unfinished object are not found - - @storage @SDK-STORAGE-008 - Scenario: Object visibility controls anonymous reads - Given an authenticated client - When the client makes the contract object public and private again - Then the SDK operation succeeds - And anonymous reads return the original bytes only while the object is public diff --git a/features/contract/storage.feature b/features/contract/storage.feature deleted file mode 100644 index bcc24941..00000000 --- a/features/contract/storage.feature +++ /dev/null @@ -1,9 +0,0 @@ -Feature: SDK storage contract - - @storage @SDK-STORAGE-001 - Scenario: Uploaded bytes can be downloaded unchanged - Given an authenticated client - When the client uploads and downloads the contract object - Then the SDK operation succeeds - And the downloaded bytes equal the uploaded bytes - And the stored object path equals the contract path diff --git a/features/contract_fixture.py b/features/contract_fixture.py deleted file mode 100644 index d2973311..00000000 --- a/features/contract_fixture.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Typed boundary for the shared SDK contract fixture.""" - -from __future__ import annotations - -from typing import TypeAlias, TypedDict, TypeGuard - -ContractRow: TypeAlias = dict[str, str] - - -class UpdateRows(TypedDict): - """Expected before and after values for an update scenario.""" - - before: ContractRow - after: ContractRow - - -class MutationRows(TypedDict): - """Rows used by the shared mutation scenarios.""" - - insert: ContractRow - update: UpdateRows - delete: ContractRow - - -class ContractFixture(TypedDict): - """Fields provisioned by Hosting for the Python contract runner.""" - - api_url: str - project_id: str - anon_key: str - service_key: str - platform_token: str - user_id: str - user_email: str - user_password: str - database_name: str - table_name: str - query_table_name: str - fixture_row: ContractRow - mutation_rows: MutationRows - bucket_name: str - storage_path: str - realtime_channel: str - realtime_table_name: str - lock_key: str - function_name: str - function_id: str - durable_function_name: str - logs_access_token: str - - -STRING_FIELDS = ( - "api_url", - "project_id", - "anon_key", - "service_key", - "platform_token", - "user_id", - "user_email", - "user_password", - "database_name", - "table_name", - "query_table_name", - "bucket_name", - "storage_path", - "realtime_channel", - "realtime_table_name", - "lock_key", - "function_name", - "function_id", - "durable_function_name", - "logs_access_token", -) - - -def is_object_dict(value: object) -> TypeGuard[dict[object, object]]: - """Narrow a decoded dictionary without trusting its keys or values. - - Returns: - Whether the value is a dictionary. - """ - return isinstance(value, dict) - - -def is_contract_row(value: object) -> TypeGuard[ContractRow]: - """Check the fields read by the database scenarios. - - Returns: - Whether the value contains the required row fields. - """ - if not is_object_dict(value): - return False - return ( - all( - isinstance(key, str) and isinstance(item, str) - for key, item in value.items() - ) - and "slug" in value - and "value" in value - ) - - -def is_update_rows(value: object) -> TypeGuard[UpdateRows]: - """Check both states of the update scenario. - - Returns: - Whether both update states contain valid rows. - """ - if not is_object_dict(value): - return False - return is_contract_row(value.get("before")) and is_contract_row(value.get("after")) - - -def is_mutation_rows(value: object) -> TypeGuard[MutationRows]: - """Check the three mutation fixtures. - - Returns: - Whether the insert, update, and delete rows are valid. - """ - if not is_object_dict(value): - return False - return ( - is_contract_row(value.get("insert")) - and is_update_rows(value.get("update")) - and is_contract_row(value.get("delete")) - ) - - -def is_contract_fixture(value: object) -> TypeGuard[ContractFixture]: - """Validate every field consumed by the shared scenarios. - - Returns: - Whether the fixture has every required field and row. - """ - if not is_object_dict(value): - return False - if any(not isinstance(value.get(key), str) for key in STRING_FIELDS): - return False - return is_contract_row(value.get("fixture_row")) and is_mutation_rows( - value.get("mutation_rows") - ) diff --git a/features/contract_support.py b/features/contract_support.py deleted file mode 100644 index 10f78ec5..00000000 --- a/features/contract_support.py +++ /dev/null @@ -1,263 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import secrets -import time -from dataclasses import dataclass -from typing import TYPE_CHECKING, TypeVar - -from volcano_sdk import ( - AuthenticationError, - ConflictError, - NotFoundError, - RateLimitedError, - ServerError, - Session, - TransportError, - ValidationError, - VolcanoClient, - VolcanoError, -) -from volcano_sdk.realtime import CENTRIFUGE_ERROR - -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable - - from contract_fixture import ContractFixture - - from volcano_sdk.models import ( - AuthChangeEvent, - DurableExecution, - DurableExecutionPage, - LockLease, - ) - from volcano_sdk.realtime import Channel - -HTTP_NOT_FOUND = 404 -HTTP_CONFLICT = 409 -HTTP_RATE_LIMITED = 429 -HTTP_SERVER_ERROR_MIN = 500 -HTTP_SERVER_ERROR_MAX = 599 - -# A durable execution is started asynchronously and observed through a status -# read, so it settles in seconds. Bounded, so a scenario reports a timeout -# instead of hanging the lane. -DURABLE_POLL_INTERVAL_SECONDS = 5 -DURABLE_POLL_TIMEOUT_SECONDS = 300 - -# The platform token is not an auth session: it cannot be refreshed and belongs -# to no auth user. The SDK carries a credential as a session and requires a -# complete one, so the rest of the owner's session is a placeholder. -OWNER_SESSION_PLACEHOLDER = "sdk-contract-platform-token-has-no-auth-session" - -CONTRACT_EXCEPTIONS = ( - CENTRIFUGE_ERROR, - KeyError, - OSError, - RuntimeError, - TypeError, - ValueError, - VolcanoError, -) - -_ResultT = TypeVar("_ResultT") - - -@dataclass(frozen=True, slots=True) -class Outcome: - ok: bool - value: object = None - category: str | None = None - error: Exception | None = None - - -def classify_error(error: Exception) -> str: - categories = ( - (AuthenticationError, "authentication error"), - (ValidationError, "validation error"), - (NotFoundError, "not found"), - (ConflictError, "conflict"), - (RateLimitedError, "rate limited"), - (ServerError, "server error"), - (TransportError, "transport error"), - ) - matched_category = next( - ( - category - for error_type, category in categories - if isinstance(error, error_type) - ), - None, - ) - if matched_category is not None: - return matched_category - if isinstance(error, VolcanoError): - return classify_status(error.status) - return "transport error" - - -def classify_status(status: int | None) -> str: - category_by_status = { - 401: "authentication error", - 403: "authentication error", - 400: "validation error", - 422: "validation error", - HTTP_NOT_FOUND: "not found", - HTTP_CONFLICT: "conflict", - HTTP_RATE_LIMITED: "rate limited", - } - if status is None: - return "transport error" - if HTTP_SERVER_ERROR_MIN <= status <= HTTP_SERVER_ERROR_MAX: - return "server error" - return category_by_status.get(status, "transport error") - - -class ContractWorld: - def __init__(self, fixture: ContractFixture) -> None: - self.fixture: ContractFixture = fixture - self.client: VolcanoClient = VolcanoClient( - api_url=fixture["api_url"], - anon_key=fixture["anon_key"], - ) - self.service_client: VolcanoClient = VolcanoClient( - api_url=fixture["api_url"], - anon_key=fixture["anon_key"], - service_key=fixture["service_key"], - ) - # Reading or stopping an execution is owner-scoped, so its client - # carries the project's own token as its session. Neither key above can - # reach those routes. - self.owner_client: VolcanoClient = VolcanoClient( - api_url=fixture["api_url"], - anon_key=fixture["anon_key"], - ) - _ = self.owner_client.auth.set_session( - Session( - access_token=fixture["platform_token"], - refresh_token=OWNER_SESSION_PLACEHOLDER, - user_id=OWNER_SESSION_PLACEHOLDER, - ) - ) - suffix = f"py-{os.getpid()}-{secrets.token_hex(5)}" - self.storage_path: str = f"{fixture['storage_path']}.{suffix}" - self.realtime_channel: str = f"{fixture['realtime_channel']}-{suffix}" - self.lock_key: str = f"{fixture['lock_key']}-{suffix}" - self.storage_bytes: bytes = f"volcano-sdk-contract-{suffix}".encode() - self.realtime_message: dict[str, str] = { - "event": "message", - "value": f"volcano-sdk-contract-{suffix}", - } - self.durable_execution_name: str = ( - f"{fixture['durable_function_name']}-{suffix}" - ) - self.durable_payload: dict[str, str] = { - "value": f"volcano-sdk-contract-{suffix}" - } - self.started_execution: DurableExecution | None = None - self.last_outcome: Outcome | None = None - self.previous_session: Session | None = None - self.signed_out_session: Session | None = None - self.bootstrap_cleanup: Callable[[], None] | None = None - self.auth_state_events: list[tuple[AuthChangeEvent, Session | None]] = [] - self.subscriber: Channel | None = None - self.publisher: Channel | None = None - self.realtime_clients: list[VolcanoClient] = [] - self.cleanup_callbacks: list[Callable[[], None]] = [] - self.loop: asyncio.AbstractEventLoop = asyncio.new_event_loop() - - def authenticate(self) -> None: - _ = self.client.auth.sign_in( - email=self.fixture["user_email"], - password=self.fixture["user_password"], - ) - - def run(self, operation: Awaitable[_ResultT]) -> _ResultT: - return self.loop.run_until_complete(operation) - - def start_durable_execution(self) -> DurableExecution: - execution = self.service_client.durable.start( - self.fixture["durable_function_name"], - self.durable_payload, - execution_name=self.durable_execution_name, - ) - self.started_execution = execution - return execution - - def follow_durable_execution(self, execution_id: str) -> DurableExecution: - """Poll an execution to a terminal status under the owner's credential. - - That read is also what reconciles the stored status against the - platform's, so it is the path a client waiting for a result takes. - - Returns: - The execution in a terminal state. - - Raises: - TimeoutError: If the execution stays active past the polling deadline. - """ - deadline = time.monotonic() + DURABLE_POLL_TIMEOUT_SECONDS - while True: - execution = self.owner_client.durable.get( - self.fixture["project_id"], - self.fixture["durable_function_name"], - execution_id, - ) - if execution.is_terminal: - return execution - if time.monotonic() >= deadline: - message = ( - f"durable execution {execution_id} was still " - f"{execution.status} after {DURABLE_POLL_TIMEOUT_SECONDS}s" - ) - raise TimeoutError(message) - time.sleep(DURABLE_POLL_INTERVAL_SECONDS) - - def list_durable_executions(self) -> DurableExecutionPage: - return self.owner_client.durable.list( - self.fixture["project_id"], - self.fixture["durable_function_name"], - ) - - def record(self, operation: Callable[[], object]) -> Outcome: - try: - self.last_outcome = Outcome(ok=True, value=operation()) - except CONTRACT_EXCEPTIONS as error: - self.last_outcome = Outcome( - ok=False, - category=classify_error(error), - error=error, - ) - return self.last_outcome - - def register_lock_cleanup(self, key: str, lease: LockLease) -> Callable[[], None]: - def release() -> None: - self.service_client.locks.release(key, lease) - - self.cleanup_callbacks.append(release) - return release - - def cleanup(self) -> None: - failures: list[Exception] = [] - for callback in reversed(self.cleanup_callbacks): - try: - callback() - except CONTRACT_EXCEPTIONS as error: - failures.append(error) - failures.extend(self.disconnect_realtime_clients()) - self.cleanup_callbacks.clear() - self.realtime_clients.clear() - self.loop.close() - if failures: - message = "Python contract cleanup failed" - raise ExceptionGroup(message, failures) - - def disconnect_realtime_clients(self) -> list[Exception]: - failures: list[Exception] = [] - for client in self.realtime_clients: - try: - self.run(client.realtime.disconnect()) - except CONTRACT_EXCEPTIONS as error: - failures.append(error) - return failures diff --git a/features/environment.py b/features/environment.py deleted file mode 100644 index d9f4016a..00000000 --- a/features/environment.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import json -import os -import stat -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from contract_fixture import ContractFixture, is_contract_fixture -from contract_support import ContractWorld - -if TYPE_CHECKING: - from behave.runner import Context - -FIXTURE_MODE = 0o600 -FIXTURE_ABSOLUTE_PATH_ERROR = "VOLCANO_SDK_CONTRACT_FIXTURE must be an absolute path" -FIXTURE_MODE_ERROR = "VOLCANO_SDK_CONTRACT_FIXTURE must have mode 0600" -FIXTURE_SHAPE_ERROR = ( - "VOLCANO_SDK_CONTRACT_FIXTURE must contain a complete contract fixture" -) -FIXTURE_REQUIRED_ERROR = "VOLCANO_SDK_CONTRACT_FIXTURE is required" - - -def load_fixture(path: Path) -> ContractFixture: - if not path.is_absolute(): - raise ValueError(FIXTURE_ABSOLUTE_PATH_ERROR) - mode = stat.S_IMODE(path.stat().st_mode) - if mode != FIXTURE_MODE: - raise PermissionError(FIXTURE_MODE_ERROR) - value = cast("object", json.loads(path.read_text(encoding="utf-8"))) - if not is_contract_fixture(value): - raise TypeError(FIXTURE_SHAPE_ERROR) - return value - - -def before_all(context: Context) -> None: - fixture_path = os.environ.get("VOLCANO_SDK_CONTRACT_FIXTURE") - if fixture_path is None: - raise RuntimeError(FIXTURE_REQUIRED_ERROR) - context.contract_fixture = load_fixture(Path(fixture_path)) - - -def before_scenario(context: Context, scenario: object) -> None: - del scenario - fixture = cast("object", context.contract_fixture) - if not is_contract_fixture(fixture): - raise TypeError(FIXTURE_SHAPE_ERROR) - context.contract = ContractWorld(fixture) - - -def after_scenario(context: Context, scenario: object) -> None: - del scenario - world = cast("object", context.contract) - assert isinstance(world, ContractWorld) - world.cleanup() diff --git a/features/logs_contract.py b/features/logs_contract.py deleted file mode 100644 index 3f9922cb..00000000 --- a/features/logs_contract.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -import time -from collections.abc import Mapping -from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, TypeVar -from uuid import uuid4 - -from volcano_sdk import VolcanoClient - -if TYPE_CHECKING: - from collections.abc import Callable - - from contract_support import ContractWorld - - from volcano_sdk.models import JSONValue, LogActivityResponse - -_ResultT = TypeVar("_ResultT") - -LOG_EVENT_COUNT = 3 -ACTIVITY_BUCKET_COUNT = 2 -HTTP_OK = 200 - - -def poll( - operation: Callable[[], _ResultT], - ready: Callable[[_ResultT], bool], - seconds: int, -) -> _ResultT: - deadline = time.monotonic() + seconds - while True: - result = operation() - if ready(result): - return result - assert time.monotonic() < deadline, ( - "matching logs did not arrive before deadline" - ) - time.sleep(min(1, max(0, deadline - time.monotonic()))) - - -def event_id(event: Mapping[str, JSONValue]) -> str: - identity = event["id"] - assert isinstance(identity, str) - return identity - - -def mapping_field( - value: Mapping[str, JSONValue], field: str -) -> Mapping[str, JSONValue]: - nested = value[field] - assert isinstance(nested, Mapping) - return nested - - -def event_ordinal(event: Mapping[str, JSONValue]) -> int: - ordinal = mapping_field(event, "body")["ordinal"] - assert isinstance(ordinal, int) - return ordinal - - -def bucket_count(bucket: Mapping[str, JSONValue], category: str, key: str) -> int: - counts = mapping_field(bucket, "counts") - value = mapping_field(counts, category).get(key, 0) - assert isinstance(value, int) - return value - - -def bucket_total(bucket: Mapping[str, JSONValue]) -> int: - total = bucket["total"] - assert isinstance(total, int) - return total - - -class LogContract: - def __init__(self, world: ContractWorld) -> None: - self.world: ContractWorld = world - self.client: VolcanoClient = VolcanoClient( - api_url=world.fixture["api_url"], - anon_key=world.fixture["anon_key"], - access_token=world.fixture["logs_access_token"], - timeout=10, - ) - self.marker: str = f"sdklogs{uuid4().hex}" - self.request: dict[str, JSONValue] = { - "resource": {"type": "function", "ids": [world.fixture["function_id"]]}, - "q": self.marker, - "start_time": (datetime.now(UTC) - timedelta(minutes=5)).isoformat(), - } - - def emit(self, count: int) -> None: - for ordinal in range(count): - response = self.world.service_client.functions.invoke( - self.world.fixture["function_name"], - { - "value": "contract", - "log_marker": self.marker, - "log_ordinal": ordinal, - }, - ) - assert response.status == HTTP_OK - assert response.data == {"echoed": "contract"} - self.request["end_time"] = ( - datetime.now(UTC) + timedelta(minutes=5) - ).isoformat() - - def search(self) -> list[Mapping[str, JSONValue]]: - project = self.world.fixture["project_id"] - page = poll( - lambda: self.client.logs.search(project, {**self.request, "limit": 100}), - lambda result: len(result.data) >= LOG_EVENT_COUNT, - 240, - ) - assert len(page.data) == LOG_EVENT_COUNT - expected_ids = {event_id(event) for event in page.data} - events: list[Mapping[str, JSONValue]] = [] - request = {**self.request, "limit": 1} - for _ in range(LOG_EVENT_COUNT): - page = self.client.logs.search(project, request) - assert page.limit == 1 - assert len(page.data) == 1 - events.extend(page.data) - if not page.has_more: - break - assert page.next_cursor - request = {**request, "cursor": page.next_cursor} - assert not page.has_more - assert {event_id(event) for event in events} == expected_ids - return events - - def activity(self) -> LogActivityResponse: - return poll( - lambda: self.client.logs.activity( - self.world.fixture["project_id"], {**self.request, "bucket_count": 2} - ), - lambda response: response.total >= 1, - 120, - ) - - def verify_events(self, events: list[Mapping[str, JSONValue]]) -> None: - assert len(events) == LOG_EVENT_COUNT - assert len({event_id(event) for event in events}) == LOG_EVENT_COUNT - assert sorted(event_ordinal(event) for event in events) == [0, 1, 2] - timestamps: list[datetime] = [] - for event in events: - assert event["id"] - body = mapping_field(event, "body") - assert body == { - "marker": self.marker, - "ordinal": event_ordinal(event), - } - resource = mapping_field(event, "resource") - assert resource["type"] == "function" - assert resource["id"] == self.world.fixture["function_id"] - assert event["level"] == "info" - timestamp = event["timestamp"] - assert isinstance(timestamp, str) - timestamps.append(datetime.fromisoformat(timestamp)) - assert timestamps == sorted(timestamps, reverse=True) - - def verify_activity(self, response: LogActivityResponse) -> None: - assert response.total == 1 - assert len(response.data) == ACTIVITY_BUCKET_COUNT - assert sum(bucket_total(bucket) for bucket in response.data) == 1 - assert ( - sum( - bucket_count(bucket, "resource_ids", self.world.fixture["function_id"]) - for bucket in response.data - ) - == 1 - ) - assert ( - sum(bucket_count(bucket, "levels", "info") for bucket in response.data) == 1 - ) diff --git a/features/postgres_changes.py b/features/postgres_changes.py deleted file mode 100644 index bbdfe6b2..00000000 --- a/features/postgres_changes.py +++ /dev/null @@ -1,138 +0,0 @@ -from __future__ import annotations - -import asyncio -from datetime import datetime -from typing import TYPE_CHECKING -from uuid import uuid4 - -if TYPE_CHECKING: - from collections.abc import Callable, Mapping - - from contract_support import ContractWorld - - from volcano_sdk.database import QueryBuilder - from volcano_sdk.models import JSONValue - from volcano_sdk.realtime import Channel, PostgresChange - - -class ChangeObserver: - def __init__(self, channel: Channel, table: str, row_id: JSONValue) -> None: - self.row_id: JSONValue = row_id - self.events: list[PostgresChange] = [] - self.queue: asyncio.Queue[PostgresChange] = asyncio.Queue() - self.inserts: list[PostgresChange] = [] - self.wrong_table: list[PostgresChange] = [] - self.stops: list[Callable[[], None]] = [ - channel.on_postgres_changes( - "*", schema="public", table=table, callback=self.record - ), - channel.on_postgres_changes( - "INSERT", schema="public", table=table, callback=self.record_insert - ), - channel.on_postgres_changes( - "*", - schema="public", - table=table + "_other", - callback=self.record_wrong_table, - ), - ] - - def owns(self, change: PostgresChange) -> bool: - identity = change.record.get("id") if change.record is not None else change.id - return identity == self.row_id - - def record_insert(self, change: PostgresChange) -> None: - if self.owns(change): - self.inserts.append(change) - - def record_wrong_table(self, change: PostgresChange) -> None: - if self.owns(change): - self.wrong_table.append(change) - - def record(self, change: PostgresChange) -> None: - if not self.owns(change): - return - self.events.append(change) - self.queue.put_nowait(change) - - async def next(self) -> PostgresChange: - return await asyncio.wait_for(self.queue.get(), timeout=10) - - def close(self) -> None: - for stop in self.stops: - stop() - - -def verify_change( - event: PostgresChange, - kind: str, - table: str, - row: Mapping[str, JSONValue], - *, - automatic: bool, -) -> None: - assert (event.type, event.schema, event.table) == (kind, "public", table) - _ = datetime.fromisoformat(event.timestamp) - if automatic: - assert event.record == row - assert event.id is None - assert event.mode is None - else: - assert event.id == row["id"] - assert event.mode == "lightweight" - assert event.record is None - - -def remove_row(table: QueryBuilder, row_id: JSONValue) -> None: - _ = table.delete().eq("id", row_id).execute() - - -async def verify_postgres_changes(world: ContractWorld) -> list[str]: - table_name = world.fixture["realtime_table_name"] - row: dict[str, JSONValue] = { - "id": str(uuid4()), - "value": "inserted", - "owner_id": world.fixture["user_id"], - } - table = world.client.database(world.fixture["database_name"]).from_(table_name) - world.cleanup_callbacks.append(lambda: remove_row(table, row["id"])) - channels = postgres_channels(world, table_name) - observers = [ChangeObserver(channel, table_name, row["id"]) for channel in channels] - try: - _ = await asyncio.gather(*(channel.subscribe() for channel in channels)) - for index, kind in enumerate(["INSERT", "UPDATE"]): - expected = {**row, "value": "inserted" if index == 0 else "updated"} - operation = ( - table.insert(expected) - if index == 0 - else table.update({"value": expected["value"]}).eq("id", row["id"]) - ) - assert await asyncio.to_thread(operation.execute) == [expected] - events = await asyncio.gather(*(observer.next() for observer in observers)) - for client_index, event in enumerate(events): - verify_change( - event, kind, table_name, expected, automatic=client_index == 0 - ) - for observer in observers: - assert [event.type for event in observer.events] == ["INSERT", "UPDATE"] - assert len(observer.inserts) == 1 - assert not observer.wrong_table - finally: - for observer in observers: - observer.close() - _ = await asyncio.gather(*(channel.unsubscribe() for channel in channels)) - return ["INSERT", "UPDATE"] - - -def postgres_channels(world: ContractWorld, table_name: str) -> list[Channel]: - channels: list[Channel] = [] - for index, client in enumerate(world.realtime_clients): - client.realtime.set_database_name(world.fixture["database_name"]) - channels.append( - client.realtime.channel( - "public:" + table_name, - channel_type="postgres", - auto_fetch=index == 0, - ) - ) - return channels diff --git a/features/presence_membership.py b/features/presence_membership.py deleted file mode 100644 index 6e7de640..00000000 --- a/features/presence_membership.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable, Mapping - - from contract_support import ContractWorld - - from volcano_sdk.realtime import Channel, RealtimePresenceInfo - - -class PresenceObserver: - def __init__(self, channel: Channel, user_id: str) -> None: - self.channel: Channel = channel - self.user_id: str = user_id - self.changed: asyncio.Event = asyncio.Event() - self.snapshots: list[set[str]] = [] - self.unsubscribe: Callable[[], None] = channel.on_presence_sync(self.record) - - def record(self, state: Mapping[str, RealtimePresenceInfo]) -> None: - self.snapshots.append(set(state)) - self.changed.set() - - async def wait(self, predicate: Callable[[], bool]) -> None: - async with asyncio.timeout(10): - while True: - self.changed.clear() - if predicate(): - return - _ = await self.changed.wait() - - async def roster(self, count: int) -> set[str]: - await self.wait(lambda: len(self.channel.get_presence_state()) == count) - state = self.channel.get_presence_state() - assert all(info.user == self.user_id for info in state.values()) - assert all(key == info.client and key for key, info in state.items()) - return set(state) - - -async def verify_presence_membership(world: ContractWorld) -> list[int]: - first, second = [ - client.realtime.channel(world.realtime_channel, channel_type="presence") - for client in world.realtime_clients - ] - first_observer = PresenceObserver(first, world.fixture["user_id"]) - second_observer = PresenceObserver(second, world.fixture["user_id"]) - try: - await first.subscribe() - initial = await first_observer.roster(1) - await second.subscribe() - joined = await first_observer.roster(2) - assert joined == await second_observer.roster(2) - assert initial < joined - await second.unsubscribe() - assert await first_observer.roster(1) == initial - await first_observer.wait( - lambda: observed_membership(first_observer.snapshots, initial, joined) - ) - return [1, 2, 1] - finally: - first_observer.unsubscribe() - second_observer.unsubscribe() - _ = await asyncio.gather(first.unsubscribe(), second.unsubscribe()) - - -def observed_membership( - snapshots: list[set[str]], initial: set[str], joined: set[str] -) -> bool: - expected = iter([initial, joined, initial]) - target: set[str] | None = next(expected) - for state in snapshots: - if state == target: - target = next(expected, None) - if target is None: - return True - return False diff --git a/features/staged/README.md b/features/staged/README.md deleted file mode 100644 index fef610f1..00000000 --- a/features/staged/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Staged contract scenarios - -These copies are pending additions to hosting's canonical SDK contract. They do -not count as active coverage and ordinary contract runs do not execute them. -Keep each file byte-identical to its coordinated hosting change and add its -language binding before merging the SDK change. - -Hosting activates a scenario only after every SDK has the matching copy. Once -that hosting change reaches main, move the copy into `features/contract` and -update the native copy checks. Keep active copies unchanged until activation; -hosting's current main must remain compatible throughout the rollout. diff --git a/features/steps/sdk_contract_steps.py b/features/steps/sdk_contract_steps.py deleted file mode 100644 index 24de157e..00000000 --- a/features/steps/sdk_contract_steps.py +++ /dev/null @@ -1,1531 +0,0 @@ -from __future__ import annotations - -import asyncio -import time -from collections.abc import Mapping -from contextlib import suppress -from dataclasses import replace -from datetime import datetime -from typing import TYPE_CHECKING, TypedDict, TypeGuard, TypeVar, cast -from uuid import uuid4 - -import httpx -from behave import given, then, when -from broadcast_pause import verify_broadcast_pause -from contract_support import ( - CONTRACT_EXCEPTIONS, - ContractWorld, - Outcome, - classify_error, -) -from logs_contract import LogContract -from postgres_changes import verify_postgres_changes -from presence_membership import verify_presence_membership - -from volcano_sdk import ( - DurableExecution, - DurableExecutionPage, - FunctionResponse, - LockLease, - LockState, - LogActivityResponse, - NotFoundError, - Session, - SessionPage, - User, - VolcanoClient, -) -from volcano_sdk.models import ( - StorageObject, - UploadPart, - UploadSession, - UploadSessionStatus, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - from behave.runner import Context - - from volcano_sdk.database import QueryBuilder - from volcano_sdk.models import JSONValue - from volcano_sdk.realtime import Channel - from volcano_sdk.storage import StorageBucket - -ACCESS_TOKEN_CLOCK_TICK_SECONDS = 1.1 -HTTP_OK = 200 -MULTIPART_PART_COUNT = 2 -DURABLE_START_COUNT = 2 -STORAGE_LIFECYCLE_DOWNLOAD_COUNT = 4 -REJECTED_BEARER = "sdk-contract-rejected-access-token" -PRESENCE_ROSTERS_SUFFIX = "and the original handler observes membership changes" -_ValueT = TypeVar("_ValueT") - - -class PartialUpload(TypedDict): - session: UploadSession - part: UploadPart - bytes: bytes - - -class ResumedUpload(PartialUpload): - progress: UploadSessionStatus - object: StorageObject - download: bytes - - -class StorageDownloadResult(TypedDict): - bytes: bytes - path: str - - -class StorageMetadataResult(StorageDownloadResult): - content_type: str - listed: list[dict[str, str]] - - -class StorageLifecycleResult(TypedDict): - bytes: list[bytes] - after_move: list[str] - after_remove: list[str] - - -class StorageVisibilityResult(TypedDict): - statuses: list[int] - bytes: bytes - visibility: list[bool] - private_bytes: list[bytes] - - -class LockReleaseResult(TypedDict): - lease: LockLease - released: bool - - -class LockRecoveryResult(TypedDict): - token: str - cleanup: Callable[[], None] - lease: LockLease - recovered: LockLease - held: LockState - renewed: LockLease - available: LockState - - -def uploaded_text_field(uploaded: dict[str, object], field: str) -> str: - value = uploaded[field] - assert isinstance(value, str) - return value - - -def _world(context: Context) -> ContractWorld: - world = cast("object", context.contract) - assert isinstance(world, ContractWorld) - return world - - -def _logs_contract(context: Context) -> LogContract: - logs_contract = cast("object", context.logs_contract) - assert isinstance(logs_contract, LogContract) - return logs_contract - - -def _outcome(context: Context) -> Outcome: - outcome = _world(context).last_outcome - assert outcome is not None - return outcome - - -def _value(context: Context, expected: type[_ValueT]) -> _ValueT: - value = _outcome(context).value - assert isinstance(value, expected) - return value - - -def _value_dict(context: Context) -> dict[str, object]: - value = _outcome(context).value - assert isinstance(value, dict) - entries = cast("dict[object, object]", value) - result: dict[str, object] = {} - for key, item in entries.items(): - assert isinstance(key, str) - result[key] = item - return result - - -def _object_list(value: object) -> list[object]: - assert isinstance(value, list) - return cast("list[object]", value) - - -def _is_json_value(value: object) -> TypeGuard[JSONValue]: - if value is None or isinstance(value, (str, int, float, bool)): - return True - if isinstance(value, (list, tuple)): - items = cast("list[object] | tuple[object, ...]", value) - return all(_is_json_value(item) for item in items) - if isinstance(value, Mapping): - entries = cast("Mapping[object, object]", value) - return all( - isinstance(key, str) and _is_json_value(item) - for key, item in entries.items() - ) - return False - - -def _log_events(value: object) -> list[Mapping[str, JSONValue]]: - assert isinstance(value, list) - events = cast("list[object]", value) - assert all(_is_json_value(event) and isinstance(event, Mapping) for event in events) - return cast("list[Mapping[str, JSONValue]]", value) - - -def _remove_cleanup(world: ContractWorld, candidate: object) -> None: - for callback in world.cleanup_callbacks: - if callback is candidate: - world.cleanup_callbacks.remove(callback) - return - msg = "Expected registered contract cleanup callback" - raise AssertionError(msg) - - -@given("the confirmed contract user") -def confirmed_contract_user(context: Context) -> None: - assert _world(context).fixture["user_id"] - - -@given("the client listens for auth state changes") -def listen_for_auth_state_changes(context: Context) -> None: - world = _world(context) - subscription = world.client.auth.on_auth_state_change( - lambda event, session: world.auth_state_events.append((event, session)) - ) - world.cleanup_callbacks.append(subscription.unsubscribe) - - -@when("the client signs in with the contract user's credentials") -def sign_in(context: Context) -> None: - world = _world(context) - _ = world.record( - lambda: world.client.auth.sign_in( - email=world.fixture["user_email"], - password=world.fixture["user_password"], - ) - ) - - -@when("the client reads the current session") -def read_current_session(context: Context) -> None: - world = _world(context) - _ = world.record(world.client.auth.get_session) - - -@when("a fresh client adopts the current session") -def adopt_current_session(context: Context) -> None: - world = _world(context) - source = world.client.auth.get_session() - assert source is not None - target = VolcanoClient( - api_url=world.fixture["api_url"], - anon_key=world.fixture["anon_key"], - ) - _ = world.record(lambda: target.auth.set_session(source)) - world.client = target - - -@when("the client refreshes the current session") -def refresh_current_session(context: Context) -> None: - world = _world(context) - world.previous_session = world.client.auth.get_session() - assert world.previous_session is not None - time.sleep(ACCESS_TOKEN_CLOCK_TICK_SECONDS) - _ = world.record(world.client.auth.refresh_session) - - -@when("a fresh client tries to refresh a supplied profile without a session identifier") -def refresh_supplied_profile_without_sid(context: Context) -> None: - world = _world(context) - source = world.client.auth.get_session() - assert source is not None - target = VolcanoClient( - api_url=world.fixture["api_url"], anon_key=world.fixture["anon_key"] - ) - supplied = target.auth.set_session(replace(source, access_token=REJECTED_BEARER)) - _ = world.record(target.auth.refresh_session) - assert target.auth.get_session() == supplied - - -@when("a fresh client starts with only the current access token") -def bootstrap_access_token(context: Context) -> None: - world = _world(context) - source = world.client - world.previous_session = source.auth.get_session() - assert world.previous_session is not None - world.bootstrap_cleanup = source.auth.sign_out - world.cleanup_callbacks.append(world.bootstrap_cleanup) - world.client = VolcanoClient( - api_url=world.fixture["api_url"], - anon_key=world.fixture["anon_key"], - access_token=world.previous_session.access_token, - ) - _ = world.record(world.client.auth.get_session) - - -@then("the token-only session has no cached user") -def token_session_has_no_user(context: Context) -> None: - session = _world(context).client.current_session - assert session is not None - assert session.user_id is None - assert session.user is None - - -@when("a fresh client starts with a rejected access token") -def bootstrap_rejected_token(context: Context) -> None: - world = _world(context) - world.client = VolcanoClient( - api_url=world.fixture["api_url"], - anon_key=world.fixture["anon_key"], - access_token=REJECTED_BEARER, - ) - world.previous_session = world.client.current_session - _ = world.record(world.client.auth.get_session) - - -@then("the session retains only the supplied access token") -def token_session_retains_access(context: Context) -> None: - world = _world(context) - session = world.client.current_session - assert session is not None - assert world.previous_session is not None - assert session.access_token == world.previous_session.access_token - assert session.refresh_token is None - - -@then("the refreshed session becomes current") -def refreshed_session_becomes_current(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert world.previous_session is not None - refreshed = _value(context, Session) - assert refreshed is not world.previous_session - assert refreshed.access_token != world.previous_session.access_token - assert world.client.auth.get_session() is refreshed - - -@when("the client signs out") -def sign_out(context: Context) -> None: - world = _world(context) - world.signed_out_session = world.client.auth.get_session() - assert world.signed_out_session is not None - outcome = world.record(world.client.auth.sign_out) - if outcome.ok and world.bootstrap_cleanup is not None: - world.cleanup_callbacks.remove(world.bootstrap_cleanup) - world.bootstrap_cleanup = None - - -@then("the current session is empty") -def current_session_is_empty(context: Context) -> None: - assert _world(context).client.auth.get_session() is None - - -@when("a fresh client loads a profile with the signed-out access token") -def load_signed_out_profile(context: Context) -> None: - world = _world(context) - assert world.signed_out_session is not None - target = VolcanoClient( - api_url=world.fixture["api_url"], - anon_key=world.fixture["anon_key"], - access_token=world.signed_out_session.access_token, - ) - _ = world.record(target.auth.get_user) - - -@when("a fresh client tries to refresh the signed-out session") -def refresh_signed_out_session(context: Context) -> None: - world = _world(context) - assert world.signed_out_session is not None - target = VolcanoClient( - api_url=world.fixture["api_url"], - anon_key=world.fixture["anon_key"], - ) - _ = target.auth.set_session(world.signed_out_session) - world.client = target - _ = world.record(target.auth.refresh_session) - - -@then("the SDK operation fails with an authentication error") -def operation_fails_with_authentication_error(context: Context) -> None: - outcome = _world(context).last_outcome - assert outcome is not None - assert not outcome.ok - assert outcome.category == "authentication error" - - -@then("the SDK operation succeeds") -def operation_succeeds(context: Context) -> None: - outcome = _world(context).last_outcome - assert outcome is not None - assert outcome.ok, f"SDK operation failed ({outcome.category}): {outcome.error}" - - -@then("the SDK operation fails") -def operation_fails(context: Context) -> None: - outcome = _world(context).last_outcome - assert outcome is not None - assert not outcome.ok - - -@then("the current session belongs to the contract user") -def session_belongs_to_contract_user(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert _value(context, Session).user_id == world.fixture["user_id"] - assert world.client.current_session is not None - assert world.client.current_session.user_id == world.fixture["user_id"] - - -@then("the current session exposes access and refresh tokens") -def session_exposes_tokens(context: Context) -> None: - world = _world(context) - session = world.client.current_session - assert session is not None - assert session.access_token - assert session.refresh_token - - -@then("the auth-state listener observes the signed-in contract user") -def auth_state_listener_observes_signed_in_user(context: Context) -> None: - world = _world(context) - assert any( - event == "SIGNED_IN" - and session is not None - and session.user_id == world.fixture["user_id"] - for event, session in world.auth_state_events - ) - - -@given("an authenticated client") -def authenticated_client(context: Context) -> None: - _world(context).authenticate() - - -@given("the client replaces its access token with a rejected token") -def replace_access_token(context: Context) -> None: - world = _world(context) - client = world.client - session = client.auth.get_session() - assert session is not None - header, payload, _signature = session.access_token.split(".") - world.previous_session = client.auth.set_session( - Session( - access_token=f"{header}.{payload}.sdk-contract-rejected-signature", - refresh_token=session.refresh_token, - user_id=session.user_id, - ) - ) - - -@then("the function invocation replaces the rejected token for the same user") -@then("the session list replaces the rejected token for the same user") -@then("the profile read replaces the rejected token for the same user") -@then("the storage operation replaces the rejected token for the same user") -@then("the database read replaces the rejected token for the same user") -def read_replaced_token(context: Context) -> None: - world = _world(context) - session = world.client.auth.get_session() - assert session is not None - assert session.access_token - assert world.previous_session is not None - assert session.access_token != world.previous_session.access_token - assert session.refresh_token - assert session.user_id == world.fixture["user_id"] - - -@when("the client lists its server sessions") -def list_server_sessions(context: Context) -> None: - world = _world(context) - _ = world.record(lambda: world.client.auth.list_sessions(page=1, limit=100)) - - -@then("the session list contains the current session for the contract user") -def listed_sessions_belong_to_contract_user(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - page = _value(context, SessionPage) - assert page.page == 1 - assert page.total >= len(page.sessions) > 0 - assert all(session.user_id == world.fixture["user_id"] for session in page.sessions) - assert sum(session.is_current for session in page.sessions) == 1 - - -@when("the client loads its server-validated profile") -def load_server_profile(context: Context) -> None: - world = _world(context) - _ = world.record(world.client.auth.get_user) - - -@then("the returned and cached profiles belong to the contract user") -def profiles_belong_to_contract_user(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert _value(context, User).id == world.fixture["user_id"] - session = world.client.current_session - assert session is not None - assert session.user is not None - assert session.user["id"] == world.fixture["user_id"] - - -@when("one client pauses delivery for 1 second and then resumes with the same handler") -def pause_and_resume(context: Context) -> None: - world = _world(context) - if world.last_outcome is not None and not world.last_outcome.ok: - return - _ = world.record(lambda: world.run(verify_broadcast_pause(world))) - - -@when('the client selects the contract table where "slug" equals the fixture slug') -def select_fixture_row(context: Context) -> None: - world = _world(context) - _ = world.record( - lambda: ( - world.client.database(world.fixture["database_name"]) - .from_(world.fixture["table_name"]) - .select("*") - .eq("slug", world.fixture["fixture_row"]["slug"]) - .execute() - ) - ) - - -@then("exactly the fixture row is returned") -def fixture_row_returned(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert world.last_outcome.value == [world.fixture["fixture_row"]] - - -@when("the client selects a projected page of query fixture members") -def select_projected_query_page(context: Context) -> None: - world = _world(context) - _ = world.record( - lambda: ( - world.client.database(world.fixture["database_name"]) - .from_(world.fixture["query_table_name"]) - .select("slug", "rank") - .in_("slug", ["alpha", "beta", "gamma", "delta"]) - .order("enabled") - .order("rank", ascending=False) - .offset(1) - .limit(2) - .execute() - ) - ) - - -@then("the projected page contains only beta and gamma in that order") -def projected_query_page_returned(context: Context) -> None: - assert _outcome(context).value == [ - {"slug": "beta", "rank": 20}, - {"slug": "gamma", "rank": 30}, - ] - - -def _query_filters( - world: ContractWorld, filters: list[tuple[str, str, object]] -) -> None: - def operation() -> dict[str, list[dict[str, object]]]: - table = world.client.database(world.fixture["database_name"]).from_( - world.fixture["query_table_name"] - ) - results: dict[str, list[dict[str, object]]] = {} - for operator, column, value in filters: - query = table.select("slug") - value_filters: dict[str, Callable[[str, object], QueryBuilder]] = { - "neq": query.neq, - "gt": query.gt, - "gte": query.gte, - "lt": query.lt, - "lte": query.lte, - "is_": query.is_, - } - if operator in value_filters: - filtered = value_filters[operator](column, value) - else: - assert isinstance(value, str) - pattern_filters: dict[str, Callable[[str, str], QueryBuilder]] = { - "like": query.like, - "ilike": query.ilike, - } - filtered = pattern_filters[operator](column, value) - results[f"{operator}:{value}"] = filtered.order("rank").execute() - return results - - _ = world.record(operation) - - -@when("the client selects query fixture rows with each comparison filter") -def select_query_comparisons(context: Context) -> None: - _query_filters( - _world(context), - [ - (op, "rank", value) - for op, value in [ - ("neq", 20), - ("gt", 20), - ("gte", 20), - ("lt", 30), - ("lte", 30), - ] - ], - ) - - -@then("each comparison returns exactly the matching query fixture rows") -def comparison_query_rows_returned(context: Context) -> None: - expected = { - "neq:20": ["alpha", "gamma", "delta", "epsilon"], - "gt:20": ["gamma", "delta", "epsilon"], - "gte:20": ["beta", "gamma", "delta", "epsilon"], - "lt:30": ["alpha", "beta"], - "lte:30": ["alpha", "beta", "gamma"], - } - assert _outcome(context).value == { - key: [{"slug": slug} for slug in slugs] for key, slugs in expected.items() - } - - -@when( - "the client selects query fixture rows with case-sensitive and insensitive patterns" -) -def select_query_patterns(context: Context) -> None: - _query_filters( - _world(context), [("like", "label", "Case_%"), ("ilike", "label", "case_%")] - ) - - -@then("each pattern returns exactly the matching query fixture rows") -def pattern_query_rows_returned(context: Context) -> None: - assert _outcome(context).value == { - "like:Case_%": [{"slug": "alpha"}, {"slug": "epsilon"}], - "ilike:case_%": [{"slug": "alpha"}, {"slug": "beta"}, {"slug": "epsilon"}], - } - - -@when("the client selects query fixture rows with null and boolean filters") -def select_query_identities(context: Context) -> None: - _query_filters( - _world(context), - [("is_", "label", None), ("is_", "enabled", True), ("is_", "enabled", False)], - ) - - -@then("each identity filter returns exactly the matching query fixture rows") -def identity_query_rows_returned(context: Context) -> None: - assert _outcome(context).value == { - "is_:None": [{"slug": "gamma"}], - "is_:True": [{"slug": "alpha"}, {"slug": "gamma"}, {"slug": "epsilon"}], - "is_:False": [{"slug": "beta"}, {"slug": "delta"}], - } - - -@when("the client inserts its contract row") -def insert_contract_row(context: Context) -> None: - world = _world(context) - row = world.fixture["mutation_rows"]["insert"] - table = world.client.database(world.fixture["database_name"]).from_( - world.fixture["table_name"] - ) - - def operation() -> list[dict[str, object]]: - def cleanup() -> None: - _ = table.delete().eq("slug", row["slug"]).execute() - - world.cleanup_callbacks.append(cleanup) - return table.insert(row).execute() - - _ = world.record(operation) - - -@then("exactly the inserted contract row is returned") -def inserted_contract_row_returned(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - expected = world.fixture["mutation_rows"]["insert"] - assert world.last_outcome.value == [expected] - - -@when("the client updates its contract row") -def update_contract_row(context: Context) -> None: - world = _world(context) - row = world.fixture["mutation_rows"]["update"] - table = world.client.database(world.fixture["database_name"]).from_( - world.fixture["table_name"] - ) - - def operation() -> list[dict[str, object]]: - def cleanup() -> None: - _ = ( - table.update({"value": row["before"]["value"]}) - .eq("slug", row["before"]["slug"]) - .execute() - ) - - world.cleanup_callbacks.append(cleanup) - return ( - table.update({"value": row["after"]["value"]}) - .eq("slug", row["before"]["slug"]) - .execute() - ) - - _ = world.record(operation) - - -@then("exactly the updated contract row is returned") -def updated_contract_row_returned(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - expected = world.fixture["mutation_rows"]["update"]["after"] - assert world.last_outcome.value == [expected] - - -@when("the client deletes its contract row") -def delete_contract_row(context: Context) -> None: - world = _world(context) - row = world.fixture["mutation_rows"]["delete"] - table = world.client.database(world.fixture["database_name"]).from_( - world.fixture["table_name"] - ) - - def operation() -> list[dict[str, object]]: - def cleanup() -> None: - _ = table.delete().eq("slug", row["slug"]).execute() - _ = table.insert(row).execute() - - world.cleanup_callbacks.append(cleanup) - return table.delete().eq("slug", row["slug"]).execute() - - _ = world.record(operation) - - -@then("exactly the deleted contract row is returned") -def deleted_contract_row_returned(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - expected = world.fixture["mutation_rows"]["delete"] - assert world.last_outcome.value == [expected] - - -@when("the client updates a missing contract row") -def update_missing_contract_row(context: Context) -> None: - world = _world(context) - table = world.client.database(world.fixture["database_name"]).from_( - world.fixture["table_name"] - ) - missing_slug = f"{world.fixture['fixture_row']['slug']}-missing" - _ = world.record( - lambda: ( - table.update({"value": "must-not-be-written"}) - .eq("slug", missing_slug) - .execute() - ) - ) - - -@when("the client deletes a missing contract row") -def delete_missing_contract_row(context: Context) -> None: - world = _world(context) - table = world.client.database(world.fixture["database_name"]).from_( - world.fixture["table_name"] - ) - missing_slug = f"{world.fixture['fixture_row']['slug']}-missing" - _ = world.record(lambda: table.delete().eq("slug", missing_slug).execute()) - - -@then("the mutation returns an empty row list") -def mutation_returns_empty_list(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert world.last_outcome.value == [] - - -@then("the existing contract row is unchanged") -def existing_contract_row_unchanged(context: Context) -> None: - world = _world(context) - row = world.fixture["fixture_row"] - result = ( - world.client.database(world.fixture["database_name"]) - .from_(world.fixture["table_name"]) - .select("*") - .eq("slug", row["slug"]) - .execute() - ) - assert result == [row] - - -@when("the client uploads and downloads the contract object") -def upload_and_download(context: Context) -> None: - world = _world(context) - - def operation() -> StorageDownloadResult: - bucket = world.client.storage.from_(world.fixture["bucket_name"]) - uploaded = bucket.upload(world.storage_path, world.storage_bytes) - return { - "bytes": bucket.download(world.storage_path), - "path": uploaded_text_field(uploaded, "name"), - } - - _ = world.record(operation) - - -@then("the downloaded bytes equal the uploaded bytes") -def downloaded_bytes_match(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert _value_dict(context)["bytes"] == world.storage_bytes - - -@when( - "the client uploads the contract object as text/plain and reads its stored metadata" -) -def upload_and_read_metadata(context: Context) -> None: - world = _world(context) - - def operation() -> StorageMetadataResult: - bucket = world.client.storage.from_(world.fixture["bucket_name"]) - uploaded = bucket.upload( - world.storage_path, world.storage_bytes, content_type="text/plain" - ) - - def remove_object() -> None: - _ = bucket.remove(world.storage_path) - - world.cleanup_callbacks.append(remove_object) - listed = bucket.list(world.storage_path) - return { - "path": uploaded_text_field(uploaded, "name"), - "bytes": bucket.download(world.storage_path), - "content_type": uploaded_text_field(uploaded, "mime_type"), - "listed": [ - {"name": item.name, "mime_type": item.mime_type} - for item in listed.objects - ], - } - - _ = world.record(operation) - - -@then("the uploaded and listed object content types are text/plain") -def stored_content_types_match(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - assert value["content_type"] == "text/plain" - assert value["listed"] == [{"name": world.storage_path, "mime_type": "text/plain"}] - - -@when("the client uploads the contract object and downloads bytes 2 through 7") -def upload_and_download_range(context: Context) -> None: - world = _world(context) - - def operation() -> StorageDownloadResult: - bucket = world.client.storage.from_(world.fixture["bucket_name"]) - uploaded = bucket.upload(world.storage_path, world.storage_bytes) - - def remove_object() -> None: - _ = bucket.remove(world.storage_path) - - world.cleanup_callbacks.append(remove_object) - return { - "bytes": bucket.download(world.storage_path, byte_range="bytes=2-7"), - "path": uploaded_text_field(uploaded, "name"), - } - - _ = world.record(operation) - - -@then("the downloaded bytes equal uploaded bytes 2 through 7 inclusive") -def downloaded_range_matches(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert _value_dict(context)["bytes"] == world.storage_bytes[2:8] - - -@when("the client copies, moves, and removes a copy of the contract object") -def copy_move_and_remove(context: Context) -> None: - world = _world(context) - bucket = world.client.storage.from_(world.fixture["bucket_name"]) - source = world.storage_path - copied = f"{source}.copy" - moved = f"{source}.moved" - - for path in (source, copied, moved): - - def cleanup(object_path: str = path) -> None: - if any( - item.name == object_path for item in bucket.list(object_path).objects - ): - _ = bucket.remove(object_path) - - world.cleanup_callbacks.append(cleanup) - - def operation() -> StorageLifecycleResult: - _ = bucket.upload(source, world.storage_bytes) - _ = bucket.copy(source, copied) - original_bytes = bucket.download(source) - copied_bytes = bucket.download(copied) - _ = bucket.move(copied, moved) - moved_bytes = bucket.download(moved) - after_move = sorted(item.name for item in bucket.list(source).objects) - _ = bucket.remove(moved) - after_remove = sorted(item.name for item in bucket.list(source).objects) - remaining_bytes = bucket.download(source) - return { - "bytes": [original_bytes, copied_bytes, moved_bytes, remaining_bytes], - "after_move": after_move, - "after_remove": after_remove, - } - - _ = world.record(operation) - - -@then("the original, copied, and moved bytes equal the uploaded bytes") -def lifecycle_bytes_match(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - values = _object_list(_value_dict(context)["bytes"]) - assert all( - isinstance(value, bytes) and value == world.storage_bytes for value in values - ) - - -@then("moving the copy leaves only the original and moved paths") -def moved_paths_match(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert _value_dict(context)["after_move"] == sorted( - [world.storage_path, f"{world.storage_path}.moved"] - ) - - -@then("removing the moved object leaves the original unchanged") -def removed_path_is_absent(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - assert value["after_remove"] == [world.storage_path] - values = _object_list(value["bytes"]) - assert len(values) == STORAGE_LIFECYCLE_DOWNLOAD_COUNT - assert values[3] == world.storage_bytes - - -def _storage_bucket(world: ContractWorld) -> StorageBucket: - return world.client.storage.from_(world.fixture["bucket_name"]) - - -def _clean_storage_object(world: ContractWorld) -> None: - bucket = _storage_bucket(world) - if any( - item.name == world.storage_path - for item in bucket.list(world.storage_path).objects - ): - _ = bucket.remove(world.storage_path) - - -def _partial_upload(world: ContractWorld) -> PartialUpload: - bucket = _storage_bucket(world) - body = b"x" * (5 * 1024 * 1024) + world.storage_bytes - session = bucket.create_upload_session( - world.storage_path, - total_size=len(body), - part_size=5 * 1024 * 1024, - content_type="application/octet-stream", - ) - - def abort() -> None: - with suppress(NotFoundError): - bucket.abort_upload_session( - world.storage_path, session_id=session.session_id - ) - - world.cleanup_callbacks.append(abort) - world.cleanup_callbacks.append(lambda: _clean_storage_object(world)) - part = bucket.upload_part( - world.storage_path, - session_id=session.session_id, - part_number=1, - data=body[: session.part_size], - ) - return {"session": session, "part": part, "bytes": body} - - -@when("the client uploads one part and resumes the contract upload") -def resume_contract_upload(context: Context) -> None: - world = _world(context) - - def operation() -> ResumedUpload: - value = _partial_upload(world) - bucket = _storage_bucket(world) - session = value["session"] - progress = bucket.get_upload_session( - world.storage_path, session_id=session.session_id - ) - _ = bucket.upload_part( - world.storage_path, - session_id=session.session_id, - part_number=2, - data=value["bytes"][session.part_size :], - ) - stored_object = bucket.complete_upload_session( - world.storage_path, session_id=session.session_id - ) - download = bucket.download(world.storage_path) - return { - **value, - "progress": progress, - "object": stored_object, - "download": download, - } - - _ = world.record(operation) - - -@then("upload progress describes exactly the first uploaded part") -def partial_upload_progress(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - progress, session, part = value["progress"], value["session"], value["part"] - assert isinstance(progress, UploadSessionStatus) - assert isinstance(session, UploadSession) - assert isinstance(part, UploadPart) - upload_bytes = value["bytes"] - assert isinstance(upload_bytes, bytes) - assert progress.session_id == session.session_id - assert progress.path == world.storage_path - assert progress.content_type == "application/octet-stream" - assert progress.status == "uploading" - assert progress.total_size == len(upload_bytes) - assert progress.part_size == session.part_size == 5 * 1024 * 1024 - assert progress.total_parts == session.total_parts == MULTIPART_PART_COUNT - assert progress.parts_uploaded == 1 - assert progress.bytes_uploaded == part.size == session.part_size - assert part.part_number == 1 - assert part.etag - assert progress.parts == (part,) - - -@then("the completed multipart object preserves its path, type, and bytes") -def completed_upload_matches(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - stored = value["object"] - upload_bytes = value["bytes"] - assert isinstance(stored, StorageObject) - assert isinstance(upload_bytes, bytes) - assert stored.name == world.storage_path - assert stored.mime_type == "application/octet-stream" - assert stored.size == len(upload_bytes) - assert value["download"] == upload_bytes - - -@when("the client uploads one part and aborts the contract upload") -def abort_contract_upload(context: Context) -> None: - world = _world(context) - - def operation() -> dict[str, str]: - value = _partial_upload(world) - bucket = _storage_bucket(world) - session_id = value["session"].session_id - bucket.abort_upload_session(world.storage_path, session_id=session_id) - outcomes: dict[str, str] = {} - reads: dict[str, Callable[[], object]] = { - "session": lambda: bucket.get_upload_session( - world.storage_path, session_id=session_id - ), - "object": lambda: bucket.download(world.storage_path), - } - for name, read in reads.items(): - try: - _ = read() - except NotFoundError: - outcomes[name] = "not found" - else: - outcomes[name] = "unexpected success" - return outcomes - - _ = world.record(operation) - - -@then("the aborted session and unfinished object are not found") -def aborted_upload_is_gone(context: Context) -> None: - assert _outcome(context).value == { - "session": "not found", - "object": "not found", - } - - -@when("the client makes the contract object public and private again") -def change_contract_visibility(context: Context) -> None: - world = _world(context) - - def operation() -> StorageVisibilityResult: - bucket = _storage_bucket(world) - world.cleanup_callbacks.append(lambda: _clean_storage_object(world)) - _ = bucket.upload(world.storage_path, world.storage_bytes) - url = bucket.get_public_url(world.storage_path) - before = httpx.get(url, timeout=10) - public = bucket.update_visibility(world.storage_path, is_public=True) - visible = httpx.get(url, timeout=10) - private = bucket.update_visibility(world.storage_path, is_public=False) - after = httpx.get(url, timeout=10) - return { - "statuses": [before.status_code, visible.status_code, after.status_code], - "bytes": visible.content, - "visibility": [public.is_public, private.is_public], - "private_bytes": [before.content, after.content], - } - - _ = world.record(operation) - - -@then("anonymous reads return the original bytes only while the object is public") -def anonymous_visibility_matches(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - private_bytes = _object_list(value["private_bytes"]) - assert all( - isinstance(body, bytes) and world.storage_bytes not in body - for body in private_bytes - ) - assert {key: value[key] for key in ("statuses", "bytes", "visibility")} == { - "statuses": [404, 200, 404], - "bytes": world.storage_bytes, - "visibility": [True, False], - } - - -@then("the stored object path equals the contract path") -def stored_object_path_matches(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert _value_dict(context)["path"] == world.storage_path - - -@given("a service-role client") -def service_role_client(context: Context) -> None: - assert _world(context).fixture["service_key"] - - -@when("the client acquires and releases the contract lock") -def acquire_and_release_lock(context: Context) -> None: - world = _world(context) - - def operation() -> LockReleaseResult: - lease = world.service_client.locks.acquire(world.lock_key, ttl=10) - cleanup = world.register_lock_cleanup(world.lock_key, lease) - world.service_client.locks.release(world.lock_key, lease) - world.cleanup_callbacks.remove(cleanup) - - replacement = world.service_client.locks.acquire(world.lock_key, ttl=10) - replacement_cleanup = world.register_lock_cleanup(world.lock_key, replacement) - world.service_client.locks.release(world.lock_key, replacement) - world.cleanup_callbacks.remove(replacement_cleanup) - return {"lease": lease, "released": replacement.token != lease.token} - - _ = world.record(operation) - - -@then("the released lease is no longer held") -def released_lease_not_held(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert _value_dict(context)["released"] is True - - -@given("a project-owner client") -def project_owner_client(context: Context) -> None: - assert _world(context).fixture["platform_token"] - - -@when("the client starts the contract durable function") -def start_durable_execution(context: Context) -> None: - world = _world(context) - _ = world.record(world.start_durable_execution) - - -@when("the client starts the contract durable function twice under one execution name") -def start_durable_execution_twice(context: Context) -> None: - world = _world(context) - - def operation() -> tuple[DurableExecution, DurableExecution]: - return world.start_durable_execution(), world.start_durable_execution() - - _ = world.record(operation) - - -@when("the client recovers the contract lock with caller-owned tokens") -def recover_lock(context: Context) -> None: - world = _world(context) - - def operation() -> LockRecoveryResult: - locks, key = world.service_client.locks, world.lock_key - token, request_id = str(uuid4()), str(uuid4()) - lease = locks.acquire(key, ttl=30, token=token, request_id=request_id) - cleanup = world.register_lock_cleanup(key, lease) - recovered = locks.acquire(key, ttl=30, token=token, request_id=request_id) - held = locks.get(key, request_id=str(uuid4())) - renewed = locks.renew(key, recovered, ttl=60, request_id=str(uuid4())) - locks.release(key, renewed, request_id=str(uuid4())) - available = locks.get(key, request_id=str(uuid4())) - return { - "token": token, - "cleanup": cleanup, - "lease": lease, - "recovered": recovered, - "held": held, - "renewed": renewed, - "available": available, - } - - _ = world.record(operation) - - -@then("the started execution carries its id, function, name, region, and creation time") -def started_execution_is_addressable(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - execution = _value(context, DurableExecution) - assert execution.id - assert execution.function_id - assert execution.name == world.durable_execution_name - assert execution.region - assert isinstance(execution.created_at, datetime) - - -@then("the started execution is not terminal and carries no result") -def started_execution_is_a_handle(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - execution = _value(context, DurableExecution) - assert execution.is_terminal is False - assert execution.result is None - - -@then("both starts return the same execution") -def both_starts_return_one_execution(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - raw_executions = _outcome(context).value - assert isinstance(raw_executions, tuple) - executions = cast("tuple[object, ...]", raw_executions) - assert len(executions) == DURABLE_START_COUNT - first, second = executions - assert isinstance(first, DurableExecution) - assert isinstance(second, DurableExecution) - assert second.id == first.id - assert second.name == first.name - - -@when("the owner reads the execution until it is terminal") -def read_execution_until_terminal(context: Context) -> None: - world = _world(context) - if world.last_outcome is not None and not world.last_outcome.ok: - return - assert world.started_execution is not None - execution_id = world.started_execution.id - _ = world.record(lambda: world.follow_durable_execution(execution_id)) - - -@then("the execution succeeded carrying the function's result") -def execution_succeeded_with_result(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - execution = _value(context, DurableExecution) - assert execution.status == "succeeded" - assert execution.result == {"echoed": world.durable_payload["value"]} - - -@when("the owner lists the durable function's executions") -def list_durable_executions(context: Context) -> None: - world = _world(context) - if world.last_outcome is not None and not world.last_outcome.ok: - return - _ = world.record(world.list_durable_executions) - - -@then("the listed executions include the started execution") -def listed_executions_include_the_started_one(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert world.started_execution is not None - page = _value(context, DurableExecutionPage) - listed = {execution.id for execution in page.executions} - assert world.started_execution.id in listed - - -@then("recovery and renewal preserve the held lease until release") -def recovered_lock_lifecycle(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - held = value["held"] - available = value["available"] - cleanup = value["cleanup"] - token = value["token"] - lease = value["lease"] - recovered = value["recovered"] - renewed = value["renewed"] - assert isinstance(held, LockState) - assert isinstance(available, LockState) - assert callable(cleanup) - assert isinstance(token, str) - assert isinstance(lease, LockLease) - assert isinstance(recovered, LockLease) - assert isinstance(renewed, LockLease) - assert held.held is True - assert available.held is False - _remove_cleanup(world, cleanup) - assert token == lease.token == recovered.token == renewed.token - assert lease.fencing_token is not None - assert ( - lease.fencing_token - == recovered.fencing_token - == held.fencing_token - == renewed.fencing_token - ) - - -@when("the client acquires and force releases the contract lock") -def force_release_lock(context: Context) -> None: - world = _world(context) - - def operation() -> object: - locks, key = world.service_client.locks, world.lock_key - lease = locks.acquire(key, ttl=30) - cleanup = world.register_lock_cleanup(key, lease) - locks.force_release(key, request_id=str(uuid4())) - return {"lease": lease, "cleanup": cleanup, "available": locks.get(key)} - - _ = world.record(operation) - - -@then("the force-released lock is available") -def force_released_lock_available(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - available = value["available"] - cleanup = value["cleanup"] - assert isinstance(available, LockState) - assert callable(cleanup) - assert available.held is False - _remove_cleanup(world, cleanup) - - -@when("the client reacquires the force-released contract lock") -def reacquire_force_released_lock(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - original = _value_dict(context)["lease"] - assert isinstance(original, LockLease) - - def operation() -> object: - replacement = world.service_client.locks.acquire(world.lock_key, ttl=30) - _ = world.register_lock_cleanup(world.lock_key, replacement) - return {"original": original, "replacement": replacement} - - _ = world.record(operation) - - -@then("the replacement owner receives a higher fencing token") -def replacement_lock_fence_increases(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - value = _value_dict(context) - original = value["original"] - replacement = value["replacement"] - assert isinstance(original, LockLease) - assert isinstance(replacement, LockLease) - assert replacement.token != original.token - assert original.fencing_token is not None - assert replacement.fencing_token is not None - assert replacement.fencing_token > original.fencing_token - - -@given("two authenticated realtime clients") -def two_realtime_clients(context: Context) -> None: - world = _world(context) - try: - subscriber, publisher = _realtime_pair(world) - world.run(_subscribe_pair(subscriber, publisher)) - except CONTRACT_EXCEPTIONS as error: - world.last_outcome = Outcome( - ok=False, - category=classify_error(error), - error=error, - ) - - -def _realtime_pair(world: ContractWorld) -> tuple[Channel, Channel]: - clients = [ - world.client, - type(world.client)( - api_url=world.fixture["api_url"], - anon_key=world.fixture["anon_key"], - ), - ] - for client in clients: - _ = client.auth.sign_in( - email=world.fixture["user_email"], - password=world.fixture["user_password"], - ) - world.realtime_clients = clients - subscriber = clients[0].realtime.channel(world.realtime_channel) - publisher = clients[1].realtime.channel(world.realtime_channel) - world.subscriber = subscriber - world.publisher = publisher - return subscriber, publisher - - -async def _subscribe_pair(subscriber: Channel, publisher: Channel) -> None: - _ = await asyncio.gather(subscriber.subscribe(), publisher.subscribe()) - - -async def publish_contract_message(world: ContractWorld) -> object: - assert world.subscriber is not None - assert world.publisher is not None - received: asyncio.Future[object] = world.loop.create_future() - - def on_message(message: object) -> None: - if not received.done(): - received.set_result(message) - - _ = world.subscriber.on("message", on_message) - await world.publisher.send(world.realtime_message) - return await asyncio.wait_for(received, timeout=10) - - -@when("one client subscribes and the other publishes the contract message") -def subscribe_and_publish(context: Context) -> None: - world = _world(context) - if world.last_outcome is not None and not world.last_outcome.ok: - return - - try: - world.last_outcome = Outcome( - ok=True, value=world.run(publish_contract_message(world)) - ) - except CONTRACT_EXCEPTIONS as error: - world.last_outcome = Outcome( - ok=False, - category=classify_error(error), - error=error, - ) - - -@then("the subscriber receives the contract message within 10 seconds") -def subscriber_received_message(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert world.last_outcome.value == world.realtime_message - - -@when("the authenticated client invokes the contract function by name") -def invoke_authenticated_contract_function(context: Context) -> None: - world = _world(context) - _ = world.record( - lambda: world.client.functions.invoke( - world.fixture["function_name"], {"value": "contract"} - ) - ) - - -@when("the client invokes the contract function by name") -def invoke_contract_function(context: Context) -> None: - world = _world(context) - - def operation() -> FunctionResponse: - return world.service_client.functions.invoke( - world.fixture["function_name"], {"value": "contract"} - ) - - _ = world.record(operation) - - -@then("the function echoes the payload") -def function_echoed_payload(context: Context) -> None: - # The function is reachable only at the endpoint the platform resolved, on a - # domain the API URL does not name, so an echo coming back is what proves - # the SDK sent the request there rather than somewhere it guessed. - world = _world(context) - assert world.last_outcome is not None - response = _value(context, FunctionResponse) - assert response.status == HTTP_OK, response - assert response.data == {"echoed": "contract"}, response.data - - -@given("a read-only project logs client") -def project_logs_client(context: Context) -> None: - context.logs_contract = LogContract(_world(context)) - - -@when("the contract function emits three unique structured log events") -def emit_three_logs(context: Context) -> None: - _logs_contract(context).emit(3) - - -@when("the contract function emits one unique structured log event") -def emit_one_log(context: Context) -> None: - _logs_contract(context).emit(1) - - -@when("the client searches and paginates those events within 240 seconds") -def search_contract_logs(context: Context) -> None: - _ = _world(context).record(_logs_contract(context).search) - - -@when("the client reads matching log activity within 120 seconds") -def read_contract_log_activity(context: Context) -> None: - _ = _world(context).record(_logs_contract(context).activity) - - -@then("all three structured events retain their metadata without duplicates") -def verify_contract_logs(context: Context) -> None: - _logs_contract(context).verify_events(_log_events(_outcome(context).value)) - - -@then("activity counts exactly that event in its function and level buckets") -def verify_contract_log_activity(context: Context) -> None: - _logs_contract(context).verify_activity(_value(context, LogActivityResponse)) - - -@when("one presence client joins and leaves while the other remains subscribed") -def observe_presence_membership(context: Context) -> None: - world = _world(context) - if world.last_outcome is not None and not world.last_outcome.ok: - return - _ = world.record(lambda: world.run(verify_presence_membership(world))) - - -@then(f"both rosters identify the contract user {PRESENCE_ROSTERS_SUFFIX}") -def verify_presence_rosters(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert world.last_outcome.value == [1, 2, 1] - - -@when("the clients observe an inserted and updated contract row") -def observe_postgres_changes(context: Context) -> None: - world = _world(context) - if world.last_outcome is not None and not world.last_outcome.ok: - return - _ = world.record(lambda: world.run(verify_postgres_changes(world))) - - -@then("automatic and lightweight notifications retain metadata and row identity") -def verify_postgres_rows(context: Context) -> None: - world = _world(context) - assert world.last_outcome is not None - assert world.last_outcome.value == ["INSERT", "UPDATE"] diff --git a/maintainers/contract-typing.md b/maintainers/contract-typing.md deleted file mode 100644 index a91f62fc..00000000 --- a/maintainers/contract-typing.md +++ /dev/null @@ -1,22 +0,0 @@ -# Contract binding types - -Mypy and basedpyright check `features/` and `typings/` with the same settings as -the SDK. `types-behave` supplies Behave's development-only signatures. - -`typings/behave.pyi` describes the three synchronous decorators these bindings -use. It retains typeshed's signature-preserving callable bound and annotates -the otherwise untyped `**kwargs` as `object`. Behave 1.3.3 accepts these -arguments and returns the registered synchronous function unchanged. - -Remove this local override when the installed upstream decorators annotate -`**kwargs`. Keep it as a single-module stub: Pyright overlays the installed -partial `behave-stubs` package onto matching package directories, including -a local `typings/behave/` directory. - -Sources: [typeshed signatures](https://github.com/python/typeshed/blob/main/stubs/behave/behave/step_registry.pyi), -[Behave registration](https://github.com/behave/behave/blob/v1.3.3/behave/step_registry.py), -[Pyright partial stub resolution](https://github.com/microsoft/pyright/blob/main/packages/pyright-internal/src/partialStubService.ts). - -`src/volcano_sdk/_tests/typing/contract_steps.py` checks retained argument types and deliberately -invalid calls. The shared scenarios still require an executed contract run -against disposable infrastructure; `poe contract-check` only checks discovery. diff --git a/maintainers/quality-policy.lock.json b/maintainers/quality-policy.lock.json index d0950b85..a4c7d6e3 100644 --- a/maintainers/quality-policy.lock.json +++ b/maintainers/quality-policy.lock.json @@ -6,14 +6,12 @@ ], "extraPaths": [ ".", - "src", - "features" + "src" ], "include": [ "src", "tests", "scripts", - "features", "typings", "conftest.py" ], @@ -62,7 +60,6 @@ "mutmut": { "also_copy": [ "conftest.py", - "features", "maintainers", "tests/fixtures", "typings", @@ -131,13 +128,11 @@ "src", "tests", "scripts", - "features", "typings", "conftest.py" ], "mypy_path": [ "$MYPY_CONFIG_FILE_DIR/src", - "$MYPY_CONFIG_FILE_DIR/features", "$MYPY_CONFIG_FILE_DIR/typings" ], "python_version": "3.11", @@ -161,23 +156,9 @@ "types", "test", "coverage", - "contract-check", "package-check", "package-extras" ], - "contract-check": { - "env": { - "VOLCANO_SDK_CONTRACT_FIXTURE": "${POE_ROOT}/tests/fixtures/sdk-contract-dry-run.json" - }, - "sequence": [ - { - "cmd": "chmod 600 tests/fixtures/sdk-contract-dry-run.json" - }, - { - "cmd": "behave features/contract --dry-run --no-snippets" - } - ] - }, "coverage": { "interpreter": "bash", "shell": "set -euo pipefail\nreport_dir=\"$(mktemp -d)\"\ntrap 'rm -rf \"$report_dir\"' EXIT\ntest_status=0\nuv run --locked --isolated --python 3.12.14 pytest -c pyproject.toml -q --cov --cov-config=pyproject.toml --cov-report=term-missing --cov-report=json:\"$report_dir/coverage.json\" --junitxml=\"$report_dir/unit.xml\" || test_status=$?\nmkdir -p reports\nif test -s \"$report_dir/coverage.json\"; then\n mv \"$report_dir/coverage.json\" reports/coverage.json\nelse\n echo 'pytest-cov did not write a coverage report' >&2\n if [[ $test_status -eq 0 ]]; then test_status=1; fi\nfi\nif test -s \"$report_dir/unit.xml\"; then\n mv \"$report_dir/unit.xml\" reports/coverage-unit.xml\nelse\n echo 'pytest did not write a JUnit report' >&2\n if [[ $test_status -eq 0 ]]; then test_status=1; fi\nfi\nexit \"$test_status\"\n" @@ -222,8 +203,7 @@ ], "minversion": "9.1.1", "pythonpath": [ - ".", - "features" + "." ], "strict": true, "testpaths": [ @@ -263,11 +243,6 @@ "conftest.py": [ "D" ], - "features/**/*.py": [ - "D", - "implicit-namespace-package", - "assert" - ], "scripts/*.py": [ "print" ], diff --git a/maintainers/releasing.md b/maintainers/releasing.md index 312d66cb..66389a3f 100644 --- a/maintainers/releasing.md +++ b/maintainers/releasing.md @@ -1,5 +1,18 @@ # Release evidence and recovery +## Release flow + +Manually merge the Release Please version PR after its native checks pass. +Release Please creates the version tag and GitHub release, which automatically +triggers the existing registry publisher. The publisher runs its native checks, +builds and verifies the distribution, then publishes through trusted publishing. + +Hosting independently runs SDK acceptance against each repository's latest `main` +and records the tested commit. Acceptance does not block SDK publication. A green +Hosting run covers its recorded revisions; a later SDK merge can publish before +Hosting tests that commit. No production acceptance account or cross-repository +release gate is required. + The checked-in release and publish workflows own versioning and publication. This checklist does not authorize a release, a registry mutation or an environment approval. @@ -7,7 +20,7 @@ This checklist does not authorize a release, a registry mutation or an environme 1. Identify the release PR, exact source commit, version, tag and intended registry account. Inspect the generated changelog and package metadata. 2. Require `uv run --locked poe quality`. Verify the OpenAPI snapshot and generated output using the checked-in commands. -3. Obtain clean code and security reviews. Record the approved shared-acceptance run and exact Hosting/SDK revisions for behavior changes; dry runs and synthetic HTTP tests are not live acceptance. +3. Obtain clean code and security reviews. Use Hosting acceptance results when reviewing behavior changes. They are independent of publication; record the tested revisions without treating a dry run as live acceptance. 4. Build the wheel and source distribution locally, install it in a clean environment, and run the exact public quickstart. Retain its digest and inventory as candidate package-content evidence; this is not proof of the bytes the release workflow will later build. 5. Confirm explicit release authorization before any publication action. The existing automatic release path may publish after a release PR lands; a successful check or an unprotected environment is not itself release approval. Resolve authorization before merging a release PR rather than assuming the configured PyPI environment has a human gate. diff --git a/pyproject.toml b/pyproject.toml index 4232e5a5..e0389b25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,6 @@ dev = [ # to install for a project that uses none of it. "volcano-sdk-python[durable]", "aws-durable-execution-sdk-python-testing>=1.2.1,<2.0.0", - "behave>=1.3.3,<2.0.0", "mypy>=1.18.0", "pyrefly==1.3.1", "openapi-python-client==0.29.0", @@ -66,7 +65,6 @@ dev = [ "coverage==7.16.1", "pytest-cov==7.1.0", "pytest-socket==0.8.1", - "types-behave==1.3.0.20260408", "tox==4.62.0", "mutmut==3.8.0", ] @@ -101,7 +99,7 @@ build-constraint-dependencies = [ ] [tool.mypy] -files = ["src", "tests", "scripts", "features", "typings", "conftest.py"] +files = ["src", "tests", "scripts", "typings", "conftest.py"] python_version = "3.11" strict = true strict_equality_for_none = true @@ -128,15 +126,14 @@ enable_error_code = [ explicit_package_bases = true mypy_path = [ "$MYPY_CONFIG_FILE_DIR/src", - "$MYPY_CONFIG_FILE_DIR/features", "$MYPY_CONFIG_FILE_DIR/typings", ] exclude = ["src/volcano_sdk/_generated/"] [tool.basedpyright] stubPath = "typings" -include = ["src", "tests", "scripts", "features", "typings", "conftest.py"] -extraPaths = [".", "src", "features"] +include = ["src", "tests", "scripts", "typings", "conftest.py"] +extraPaths = [".", "src"] exclude = ["src/volcano_sdk/_generated"] pythonVersion = "3.11" venvPath = "." @@ -179,7 +176,6 @@ max-complexity = 5 [tool.ruff.lint.per-file-ignores] "conftest.py" = ["D"] -"features/**/*.py" = ["D", "implicit-namespace-package", "assert"] "scripts/*.py" = ["print"] "{tests,src/volcano_sdk/_tests}/**/*.py" = [ "D", @@ -194,7 +190,7 @@ max-complexity = 5 minversion = "9.1.1" addopts = "--disable-socket --allow-unix-socket --import-mode=importlib" testpaths = ["tests/unit", "src/volcano_sdk/_tests"] -pythonpath = [".", "features"] +pythonpath = ["."] strict = true empty_parameter_set_mark = "fail_at_collect" asyncio_mode = "auto" @@ -235,7 +231,6 @@ on_dependency_change = "rerun" process_isolation = "forkserver" also_copy = [ "conftest.py", - "features", "maintainers", "tests/fixtures", "typings", @@ -247,7 +242,7 @@ also_copy = [ [tool.poe.tasks] quality = ["checks", "mutation"] -checks = ["policy", "audit", "generated", "lint", "format-check", "types", "test", "coverage", "contract-check", "package-check", "package-extras"] +checks = ["policy", "audit", "generated", "lint", "format-check", "types", "test", "coverage", "package-check", "package-extras"] policy = "bash scripts/check_quality_policy.sh" build = "uv build --no-sources --require-hashes" audit = "bash scripts/audit_dependencies.sh" @@ -301,12 +296,6 @@ fi exit "$test_status" """ -[tool.poe.tasks.contract-check] -sequence = [ - { cmd = "chmod 600 tests/fixtures/sdk-contract-dry-run.json" }, - { cmd = "behave features/contract --dry-run --no-snippets" }, -] -env.VOLCANO_SDK_CONTRACT_FIXTURE = "${POE_ROOT}/tests/fixtures/sdk-contract-dry-run.json" [tool.poe.tasks.package-check] # Keep artifacts private to this run and remove them even when validation fails. diff --git a/scripts/check_quality_policy.py b/scripts/check_quality_policy.py index f4dd3d8c..d661292a 100644 --- a/scripts/check_quality_policy.py +++ b/scripts/check_quality_policy.py @@ -18,9 +18,8 @@ from collections.abc import Iterable GENERATED = "src/volcano_sdk/_generated" -LOCK_SHA256 = "d85da62afd3ea7353774b03f53b271fd4de8081f72b019266c600627e979c714" +LOCK_SHA256 = "715cf5a0c1a93b2b77b4c63ed739dc9fa760276d43c25756230b762febab70a6" TYPE_FIXTURES = { - "src/volcano_sdk/_tests/typing/contract_steps.py", "src/volcano_sdk/_tests/typing/durable_callbacks.py", "src/volcano_sdk/_tests/typing/durable_configuration.py", "src/volcano_sdk/_tests/typing/durable_logger.py", diff --git a/src/volcano_sdk/_tests/contract/__init__.py b/src/volcano_sdk/_tests/contract/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/volcano_sdk/_tests/contract/fakes.py b/src/volcano_sdk/_tests/contract/fakes.py deleted file mode 100644 index 31f1748c..00000000 --- a/src/volcano_sdk/_tests/contract/fakes.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Typed collaborators for the SDK contract binding tests.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import Callable, Mapping - - from volcano_sdk.realtime import RealtimePresenceInfo - - -class PauseSubscriber: - def __init__(self) -> None: - self.handler: Callable[[object], None] | None = None - self.subscribed: bool = True - self.on_count: int = 0 - - def on(self, _event: str, handler: Callable[[object], None]) -> Callable[[], None]: - self.handler = handler - self.on_count += 1 - return lambda: None - - async def subscribe(self) -> None: - self.subscribed = True - - async def unsubscribe(self) -> None: - self.subscribed = False - - -class PausePublisher: - def __init__(self, subscriber: PauseSubscriber, *, leak: bool) -> None: - self.subscriber: PauseSubscriber = subscriber - self.leak: bool = leak - - async def send(self, message: object) -> None: - if self.leak or self.subscriber.subscribed: - assert self.subscriber.handler is not None - self.subscriber.handler(message) - - -@dataclass(frozen=True) -class BucketObject: - name: str - - -@dataclass(frozen=True) -class BucketListing: - objects: list[BucketObject] - - -class FailingBucket: - def __init__(self, paths: list[str]) -> None: - self.paths: list[str] = paths - self.removed: list[str] = [] - - def list(self, _path: str) -> BucketListing: - return BucketListing([BucketObject(path) for path in self.paths]) - - def remove(self, path: str) -> None: - self.removed.append(path) - if len(self.removed) == 1: - message = "delete failed" - raise RuntimeError(message) - - -class FailingPresenceChannel: - def __init__(self, error: RuntimeError) -> None: - self.error: RuntimeError = error - - @staticmethod - def on_presence_sync( - _callback: Callable[[Mapping[str, RealtimePresenceInfo]], None], - ) -> Callable[[], None]: - return lambda: None - - @staticmethod - def get_presence_state() -> dict[str, RealtimePresenceInfo]: - return {} - - async def subscribe(self) -> None: - raise self.error - - async def unsubscribe(self) -> None: - pass diff --git a/src/volcano_sdk/_tests/contract/test_bindings.py b/src/volcano_sdk/_tests/contract/test_bindings.py deleted file mode 100644 index 73a125b6..00000000 --- a/src/volcano_sdk/_tests/contract/test_bindings.py +++ /dev/null @@ -1,873 +0,0 @@ -from __future__ import annotations - -import asyncio -import hashlib -import importlib -import json -import os -from dataclasses import replace -from datetime import UTC, datetime, timedelta -from pathlib import Path -from types import SimpleNamespace -from typing import TYPE_CHECKING, Protocol, cast, runtime_checkable -from unittest.mock import AsyncMock, Mock, call - -import behave.step_registry as behave_step_registry -import broadcast_pause -import contract_fixture -import environment as contract_environment -import httpx -import logs_contract -import postgres_changes -import presence_membership -import pytest -from behave.runner import Context -from contract_support import ContractWorld, Outcome -from steps import sdk_contract_steps - -from volcano_sdk import FunctionResponse, LogActivityResponse, Session, VolcanoClient -from volcano_sdk._tests.contract.fakes import ( - FailingBucket, - FailingPresenceChannel, - PausePublisher, - PauseSubscriber, -) -from volcano_sdk._tests.session_fixtures import access_token -from volcano_sdk._transport import GeneratedTransport -from volcano_sdk.auth import Auth -from volcano_sdk.realtime import PostgresChange - -if TYPE_CHECKING: - from collections.abc import Callable, Mapping - - from volcano_sdk.models import JSONValue - -ROOT = Path(__file__).parents[4] - - -class _Runner: - config: object = object() - - -_RUNNER = _Runner() - - -@runtime_checkable -class _StepRegistry(Protocol): - steps: object - - def clear(self) -> None: ... - - -@runtime_checkable -class _StepDefinition(Protocol): - pattern: str - - -def _registry() -> _StepRegistry: - value = cast("object", behave_step_registry.registry) - assert isinstance(value, _StepRegistry) - return value - - -def _bound_patterns() -> set[str]: - steps = _registry().steps - assert isinstance(steps, dict) - entries = cast("dict[object, object]", steps) - bound: set[str] = set() - for definitions in entries.values(): - assert isinstance(definitions, list) - for definition in cast("list[object]", definitions): - assert isinstance(definition, _StepDefinition) - assert isinstance(definition.pattern, str) - bound.add(definition.pattern) - return bound - - -def _fixture() -> contract_fixture.ContractFixture: - decoded = cast( - "object", - json.loads((ROOT / "tests/fixtures/sdk-contract-dry-run.json").read_text()), - ) - assert contract_fixture.is_contract_fixture(decoded) - return decoded - - -def _context(world: ContractWorld) -> Context: - context = Context(_RUNNER) - context.contract = world - return context - - -FEATURE_SHA256 = { - "storage-lifecycle.feature": ( - "08d00ac825bc186929904dea75af28052df964e446e7aeb0e2266711536aa88f" - ), - "storage-range.feature": ( - "807424f26ccdf72e74b359eb52a807f3ae4657f95cdef0255f636a8f588e49cd" - ), - "storage-metadata.feature": ( - "8a99fab83abf3d73e41ab8557f681b8b1659299a942a1ceb18a2f85009f5af6e" - ), - "auth.feature": "c237deb0b3be98d64689699a0ffdcd272e7ef027a2b69439b1768195042ed493", - "functions.feature": ( - "ee6d02540eb8c6216fc18b47f7ef91f5db9a45649749a2ed47d692ae42a1e4e0" - ), - "database-delete.feature": ( - "b328579ee9b33cbb313dd4d13eb899d53f64344c5e5beb00dbfb7eed2098e2c7" - ), - "database-insert.feature": ( - "46150600af9f54b690bc5c9b8f230c5ba4e3be755e50ad0921051faa95e45316" - ), - "database-update.feature": ( - "7a64470474f2ba842faf1b8352ccc2cd7efa8183212ab309c1321dffb9c093d3" - ), - "database-refresh.feature": ( - "76eb18c448899a6849e84015f1a276a984e1005eb494042b45247c67923cac89" - ), - "realtime-pause.feature": ( - "d4ff3e9cab94dfe1adabbe50e47c6ff46ca8fc48b357f1d9c425aea6d8519e33" - ), - "database.feature": ( - "4685b29357a621068b25984ff0de29cd4c504eebe5cfb597f0b999e29878a668" - ), - "durable.feature": ( - "416f7dfe1347086bf08af6f15c31467c58e21e14b278e3fd7482f7042dda06d7" - ), - "locks.feature": "76fa31f9a7c203e33b367e5ca1467b2334e7c85c960de8d5cab8638920137411", - "realtime.feature": ( - "e65862e27656cdd0afa8e552cb5a628d9831568e3299711e572ccd4f6b750696" - ), - "storage.feature": ( - "0772d46691d2a158e752d19cea995ff79db960fc3774c799ebdf081e19424d82" - ), - "auth-profile-refresh.feature": ( - "df46f1c374fcbdeb790cde0781ff14f580e56c44ac9aa6445d97469a385f6f51" - ), - "auth-request-recovery.feature": ( - "9fa6f8d6cb3bca89501d32d950f06b8982ed34b888cce134465e0358b8261e8f" - ), - "auth-token-bootstrap.feature": ( - "7f2cef1489ce2cb5a9f3ba230d7f411197415a0f4a3c14f38361c5ce0522e0ac" - ), - "database-queries.feature": ( - "37d7f2e8fd4efb035cbc094c9c91a44a86f15fbcd689627e525e8d04f033a928" - ), - "functions-refresh.feature": ( - "34ca84c8a4cf7f06addf7ff6f4c01c9546fac131e99b3d1509ae54f896d1bd9e" - ), - "locks-recovery.feature": ( - "4f4b52caf587bdc61e72cbdcc39dcf5c9b06ea8573d8b99a183c2644e375ffab" - ), - "logs.feature": "5616e288fe1a68e13fa70416fe0323a5ce830c0edaa885a387efbf9e5bb2a269", - "realtime-postgres.feature": ( - "794c2ecbb94fd262a37840f4c3fe3bd9f9ee58c22fda9df2a46de60f93e52c91" - ), - "realtime-presence.feature": ( - "b4429f6e3df60a6a98be4daf1d8517e2cd7cee651f9eb6463a1090ab49a102b5" - ), - "storage-refresh.feature": ( - "00257b455f9897791db7fd82c4166c7f34c7134b5b9f4ea92f509bba533751a1" - ), - "storage-sessions.feature": ( - "037c60a8da27ec4cc5777596ba0c669b8309181a54b27aa61882535ed2f6beb1" - ), -} - - -def test_contract_features_match_shared_source() -> None: - copied = ROOT / "features" / "contract" - assert {path.name for path in copied.glob("*.feature")} == FEATURE_SHA256.keys() - for name, expected in FEATURE_SHA256.items(): - assert hashlib.sha256((copied / name).read_bytes()).hexdigest() == expected - - -def test_contract_storage_feature_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "storage-refresh.feature" - expected = "00257b455f9897791db7fd82c4166c7f34c7134b5b9f4ea92f509bba533751a1" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_contract_profile_feature_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "auth-profile-refresh.feature" - expected = "df46f1c374fcbdeb790cde0781ff14f580e56c44ac9aa6445d97469a385f6f51" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_contract_lock_recovery_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "locks-recovery.feature" - expected = "4f4b52caf587bdc61e72cbdcc39dcf5c9b06ea8573d8b99a183c2644e375ffab" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_contract_token_bootstrap_feature_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "auth-token-bootstrap.feature" - expected = "7f2cef1489ce2cb5a9f3ba230d7f411197415a0f4a3c14f38361c5ce0522e0ac" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_contract_auth_request_feature_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "auth-request-recovery.feature" - expected = "9fa6f8d6cb3bca89501d32d950f06b8982ed34b888cce134465e0358b8261e8f" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_contract_database_queries_match_shared_source() -> None: - feature = ROOT / "features" / "contract" / "database-queries.feature" - expected = "37d7f2e8fd4efb035cbc094c9c91a44a86f15fbcd689627e525e8d04f033a928" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_contract_storage_sessions_match_shared_source() -> None: - feature = ROOT / "features" / "contract" / "storage-sessions.feature" - expected = "037c60a8da27ec4cc5777596ba0c669b8309181a54b27aa61882535ed2f6beb1" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_every_contract_phrase_is_bound_verbatim() -> None: - _registry().clear() - _ = importlib.reload(sdk_contract_steps) - bound = _bound_patterns() - assert bound == { - "the authenticated client invokes the contract function by name", - "the function invocation replaces the rejected token for the same user", - "the client selects a projected page of query fixture members", - "the projected page contains only beta and gamma in that order", - "the client selects query fixture rows with each comparison filter", - "each comparison returns exactly the matching query fixture rows", - ( - "the client selects query fixture rows with " - "case-sensitive and insensitive patterns" - ), - "each pattern returns exactly the matching query fixture rows", - "the client selects query fixture rows with null and boolean filters", - "each identity filter returns exactly the matching query fixture rows", - "the client uploads one part and resumes the contract upload", - "upload progress describes exactly the first uploaded part", - "the completed multipart object preserves its path, type, and bytes", - "the client uploads one part and aborts the contract upload", - "the aborted session and unfinished object are not found", - "the client makes the contract object public and private again", - "anonymous reads return the original bytes only while the object is public", - "the client copies, moves, and removes a copy of the contract object", - "the original, copied, and moved bytes equal the uploaded bytes", - "moving the copy leaves only the original and moved paths", - "removing the moved object leaves the original unchanged", - "the client uploads the contract object and downloads bytes 2 through 7", - "the downloaded bytes equal uploaded bytes 2 through 7 inclusive", - ( - "the client uploads the contract object as text/plain " - "and reads its stored metadata" - ), - "the uploaded and listed object content types are text/plain", - "the client replaces its access token with a rejected token", - "the database read replaces the rejected token for the same user", - "the storage operation replaces the rejected token for the same user", - "the profile read replaces the rejected token for the same user", - "the session list replaces the rejected token for the same user", - "the client lists its server sessions", - "the session list contains the current session for the contract user", - "the client loads its server-validated profile", - "the returned and cached profiles belong to the contract user", - ( - "a fresh client tries to refresh a supplied profile " - "without a session identifier" - ), - "a fresh client starts with only the current access token", - "a fresh client starts with a rejected access token", - "the token-only session has no cached user", - "the session retains only the supplied access token", - ( - "one client pauses delivery for 1 second " - "and then resumes with the same handler" - ), - "a service-role client", - "a project-owner client", - "the client starts the contract durable function", - ( - "the client starts the contract durable function twice " - "under one execution name" - ), - ( - "the started execution carries its id, function, name, region, " - "and creation time" - ), - "the started execution is not terminal and carries no result", - "both starts return the same execution", - "the owner reads the execution until it is terminal", - "the execution succeeded carrying the function's result", - "the owner lists the durable function's executions", - "the listed executions include the started execution", - "the client invokes the contract function by name", - "the function echoes the payload", - "a fresh client adopts the current session", - "a fresh client tries to refresh the signed-out session", - "a fresh client loads a profile with the signed-out access token", - "an authenticated client", - "exactly the deleted contract row is returned", - "exactly the fixture row is returned", - "exactly the inserted contract row is returned", - "exactly the updated contract row is returned", - "one client subscribes and the other publishes the contract message", - "the auth-state listener observes the signed-in contract user", - "the SDK operation succeeds", - "the client recovers the contract lock with caller-owned tokens", - "recovery and renewal preserve the held lease until release", - "the client acquires and force releases the contract lock", - "the force-released lock is available", - "the client reacquires the force-released contract lock", - "the replacement owner receives a higher fencing token", - "the SDK operation fails", - "the client acquires and releases the contract lock", - "the client deletes its contract row", - "the client deletes a missing contract row", - "the client updates a missing contract row", - "the existing contract row is unchanged", - "the mutation returns an empty row list", - "the client inserts its contract row", - 'the client selects the contract table where "slug" equals the fixture slug', - "the client reads the current session", - "the client refreshes the current session", - "the client listens for auth state changes", - "the client signs out", - "the client signs in with the contract user's credentials", - "the client uploads and downloads the contract object", - "the client updates its contract row", - "the confirmed contract user", - "the current session belongs to the contract user", - "the current session exposes access and refresh tokens", - "the current session is empty", - "the downloaded bytes equal the uploaded bytes", - "the refreshed session becomes current", - "the released lease is no longer held", - "the SDK operation fails with an authentication error", - "the stored object path equals the contract path", - "the subscriber receives the contract message within 10 seconds", - "two authenticated realtime clients", - "the clients observe an inserted and updated contract row", - "automatic and lightweight notifications retain metadata and row identity", - "a read-only project logs client", - "the contract function emits three unique structured log events", - "the contract function emits one unique structured log event", - "the client searches and paginates those events within 240 seconds", - "the client reads matching log activity within 120 seconds", - "all three structured events retain their metadata without duplicates", - "activity counts exactly that event in its function and level buckets", - "one presence client joins and leaves while the other remains subscribed", - ( - "both rosters identify the contract user " - "and the original handler observes membership changes" - ), - } - - -@pytest.mark.parametrize("leak", [False, True]) -def test_broadcast_pause_checks_silence( - monkeypatch: pytest.MonkeyPatch, *, leak: bool -) -> None: - world = ContractWorld(_fixture()) - subscriber = PauseSubscriber() - monkeypatch.setattr(world, "subscriber", subscriber) - monkeypatch.setattr(world, "publisher", PausePublisher(subscriber, leak=leak)) - world.realtime_message = {"event": "message", "value": "contract"} - sleep = AsyncMock() - monkeypatch.setattr(asyncio, "sleep", sleep) - try: - if leak: - with pytest.raises(AssertionError, match="while paused"): - _ = asyncio.run(broadcast_pause.verify_broadcast_pause(world)) - else: - assert ( - asyncio.run(broadcast_pause.verify_broadcast_pause(world)) - == world.realtime_message - ) - assert subscriber.on_count == 1 - sleep.assert_awaited_once_with(1) - finally: - world.cleanup() - - -def test_durable_idempotency_binding_starts_twice_and_records_both_handles( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _registry().clear() - _ = importlib.reload(sdk_contract_steps) - steps = sdk_contract_steps - first = SimpleNamespace(id="execution", name="contract") - second = SimpleNamespace(id="execution", name="contract") - fixture = _fixture() - world = ContractWorld(fixture) - start = Mock(side_effect=[first, second]) - monkeypatch.setattr(world, "start_durable_execution", start) - try: - steps.start_durable_execution_twice(_context(world)) - - assert start.call_args_list == [call(), call()] - assert world.last_outcome is not None - assert world.last_outcome.ok is True - assert world.last_outcome.value == (first, second) - finally: - world.cleanup() - - -def test_realtime_contract_pair_authenticates_and_owns_both_clients( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _registry().clear() - _ = importlib.reload(sdk_contract_steps) - steps = sdk_contract_steps - fixture = _fixture() - world = ContractWorld(fixture) - signed_in: list[tuple[str, str]] = [] - - def sign_in(_auth: object, *, email: str, password: str) -> None: - signed_in.append((email, password)) - - async def subscribe_pair(_subscriber: object, _publisher: object) -> None: - pass - - monkeypatch.setattr(Auth, "sign_in", sign_in) - monkeypatch.setattr(steps, "_subscribe_pair", subscribe_pair) - try: - steps.two_realtime_clients(_context(world)) - subscriber, publisher = world.subscriber, world.publisher - assert subscriber is not None - assert publisher is not None - assert len(world.realtime_clients) == 2 - assert world.subscriber is subscriber - assert world.publisher is publisher - channel_name = f"broadcast:{world.realtime_channel}" - assert subscriber.name == publisher.name == channel_name - assert signed_in == [(fixture["user_email"], fixture["user_password"])] * 2 - finally: - world.cleanup() - - -def test_lifecycle_cleanup_attempts_all_paths_after_a_deletion_failure( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _registry().clear() - _ = importlib.reload(sdk_contract_steps) - steps = sdk_contract_steps - paths = ["contract.txt", "contract.txt.copy", "contract.txt.moved"] - bucket = FailingBucket(paths) - fixture = _fixture() - world = ContractWorld(fixture) - - def bucket_for(_name: str) -> FailingBucket: - return bucket - - def record(_operation: Callable[[], object]) -> Outcome: - return Outcome(ok=True) - - monkeypatch.setattr(world.client.storage, "from_", bucket_for) - world.fixture["bucket_name"] = "assets" - world.storage_path = paths[0] - monkeypatch.setattr(world, "record", record) - steps.copy_move_and_remove(_context(world)) - - with pytest.raises(ExceptionGroup, match="Python contract cleanup failed"): - world.cleanup() - - assert bucket.removed == list(reversed(paths)) - assert world.loop.is_closed() - - -def test_fixture_loader_requires_absolute_private_file(tmp_path: Path) -> None: - environment = contract_environment - fixture = tmp_path / "fixture.json" - _ = fixture.write_text( - (ROOT / "tests/fixtures/sdk-contract-dry-run.json").read_text(), - encoding="utf-8", - ) - - fixture.chmod(0o644) - with pytest.raises(PermissionError, match="0600"): - _ = environment.load_fixture(fixture) - - fixture.chmod(0o600) - assert environment.load_fixture(fixture)["project_id"] == "dry-run-project" - - previous = Path.cwd() - os.chdir(tmp_path) - try: - with pytest.raises(ValueError, match="absolute"): - _ = environment.load_fixture(Path("fixture.json")) - finally: - os.chdir(previous) - - -def test_fixture_loader_rejects_incomplete_or_malformed_contract_data( - tmp_path: Path, -) -> None: - environment = contract_environment - fixture = tmp_path / "fixture.json" - _ = fixture.write_text( - (ROOT / "tests/fixtures/sdk-contract-dry-run.json").read_text(), - encoding="utf-8", - ) - fixture.chmod(0o600) - valid = environment.load_fixture(fixture) - invalid_cases: tuple[dict[str, object], ...] = ( - {}, - {**valid, "project_id": 3}, - {**valid, "fixture_row": {"slug": 3, "value": "value"}}, - {**valid, "mutation_rows": {**valid["mutation_rows"], "insert": {}}}, - ) - for invalid in invalid_cases: - _ = fixture.write_text(json.dumps(invalid), encoding="utf-8") - fixture.chmod(0o600) - with pytest.raises(TypeError, match="complete contract fixture"): - _ = environment.load_fixture(fixture) - - -def test_contract_fixture_validator_checks_every_declared_string_field() -> None: - fixture_module = contract_fixture - assert set(fixture_module.STRING_FIELDS) == set( - fixture_module.ContractFixture.__annotations__ - ) - {"fixture_row", "mutation_rows"} - - -@pytest.mark.parametrize("revoked", [False, True]) -def test_bootstrap_cleanup_is_disarmed_only_after_successful_revocation( - monkeypatch: pytest.MonkeyPatch, *, revoked: bool -) -> None: - _registry().clear() - _ = importlib.reload(sdk_contract_steps) - steps = sdk_contract_steps - world = ContractWorld(_fixture()) - source = world.client - _ = source.auth.set_session(Session("captured-access", "refresh", "user")) - sign_out_calls = 0 - - def sign_out() -> None: - nonlocal sign_out_calls - sign_out_calls += 1 - - def record(_operation: Callable[[], object]) -> Outcome: - return Outcome(ok=revoked) - - monkeypatch.setattr(source.auth, "sign_out", sign_out) - monkeypatch.setattr(world, "record", record) - try: - steps.bootstrap_access_token(_context(world)) - steps.sign_out(_context(world)) - finally: - world.cleanup() - assert sign_out_calls == (0 if revoked else 1) - - -def test_rejected_token_binding_preserves_refreshable_session_identity() -> None: - _registry().clear() - _ = importlib.reload(sdk_contract_steps) - steps = sdk_contract_steps - user = "00000000-0000-4000-8000-000000000001" - requests: list[httpx.Request] = [] - - def handle(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response( - 200, - json={ - "access_token": access_token("renewed"), - "refresh_token": "rotated", - "token_type": "bearer", - "expires_in": 3600, - "user": {"id": user, "email": "u@example.com", "status": "active"}, - }, - ) - - client = VolcanoClient( - anon_key="anon", - _transport=GeneratedTransport( - api_url="https://api.test", httpx_transport=httpx.MockTransport(handle) - ), - ) - _ = client.auth.set_session(Session(access_token(), "refresh", user)) - fixture = _fixture() - world = ContractWorld(fixture) - world.client = client - world.fixture["user_id"] = user - context = _context(world) - try: - steps.replace_access_token(context) - assert client.current_session is not None - assert client.current_session.access_token != access_token() - assert ( - client.current_session.access_token.split(".")[:2] - == access_token().split(".")[:2] - ) - _ = client.auth.refresh_session() - steps.read_replaced_token(context) - assert len(requests) == 1 - assert requests[0].url.path == "/auth/refresh" - finally: - world.cleanup() - - -@pytest.mark.parametrize("leaking_response", [None, 0, 1]) -def test_visibility_assertion_rejects_private_payload_leaks( - leaking_response: int | None, -) -> None: - _registry().clear() - _ = importlib.reload(sdk_contract_steps) - steps = sdk_contract_steps - content = b"private contract content" - private_bytes = [b"not found", b"not found"] - if leaking_response is not None: - private_bytes[leaking_response] = b"prefix: " + content - fixture = _fixture() - world = ContractWorld(fixture) - world.storage_bytes = content - world.last_outcome = Outcome( - ok=True, - value={ - "statuses": [404, 200, 404], - "bytes": content, - "visibility": [True, False], - "private_bytes": private_bytes, - }, - ) - context = _context(world) - try: - if leaking_response is None: - steps.anonymous_visibility_matches(context) - else: - with pytest.raises(AssertionError): - steps.anonymous_visibility_matches(context) - finally: - world.cleanup() - - -def test_contract_logs_feature_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "logs.feature" - expected = "5616e288fe1a68e13fa70416fe0323a5ce830c0edaa885a387efbf9e5bb2a269" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def test_log_contract_rejects_duplicate_and_wrong_resource_events() -> None: - world = ContractWorld(_fixture()) - world.fixture["function_id"] = "function-id" - contract = logs_contract.LogContract(world) - events: list[Mapping[str, JSONValue]] = [ - { - "id": f"event-{ordinal}", - "timestamp": f"2026-09-18T12:00:0{2 - ordinal}Z", - "body": {"marker": contract.marker, "ordinal": ordinal}, - "resource": {"type": "function", "id": "function-id"}, - "level": "info", - } - for ordinal in range(3) - ] - try: - contract.verify_events(events) - with pytest.raises(AssertionError): - contract.verify_events([events[0], events[0], events[2]]) - events[1] = { - **events[1], - "resource": {"type": "function", "id": "another-function"}, - } - with pytest.raises(AssertionError): - contract.verify_events(events) - malformed_event: dict[str, JSONValue] = {**events[1], "resource": None} - with pytest.raises(AssertionError): - contract.verify_events([events[0], malformed_event, events[2]]) - finally: - world.cleanup() - - -def test_log_activity_contract_rejects_wrong_resource_counts() -> None: - world = ContractWorld(_fixture()) - world.fixture["function_id"] = "function-id" - contract = logs_contract.LogContract(world) - - def activity(resource_ids: JSONValue) -> LogActivityResponse: - return LogActivityResponse( - total=1, - data=( - { - "total": 1, - "counts": {"resource_ids": resource_ids, "levels": {"info": 1}}, - }, - {"total": 0, "counts": {"resource_ids": {}, "levels": {}}}, - ), - ) - - try: - contract.verify_activity(activity({"function-id": 1})) - with pytest.raises(AssertionError): - contract.verify_activity(activity({"another-function": 1})) - with pytest.raises(AssertionError): - contract.verify_activity(activity(None)) - finally: - world.cleanup() - - -@pytest.mark.parametrize("server_skew_seconds", [-120, 120]) -def test_log_bounds_allow_server_clock_skew( - monkeypatch: pytest.MonkeyPatch, server_skew_seconds: int -) -> None: - world = ContractWorld(_fixture()) - - def invoke(_name: str, _payload: JSONValue) -> FunctionResponse: - return FunctionResponse( - data={"echoed": "contract"}, status=200, headers={}, version=None - ) - - monkeypatch.setattr( - world.service_client.functions, - "invoke", - invoke, - ) - contract = logs_contract.LogContract(world) - server_time = datetime.now(UTC) + timedelta(seconds=server_skew_seconds) - try: - contract.emit(1) - start = contract.request["start_time"] - end = contract.request["end_time"] - assert isinstance(start, str) - assert isinstance(end, str) - assert datetime.fromisoformat(start) < server_time - assert server_time < datetime.fromisoformat(end) - finally: - world.cleanup() - - -def test_contract_presence_feature_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "realtime-presence.feature" - expected = "b4429f6e3df60a6a98be4daf1d8517e2cd7cee651f9eb6463a1090ab49a102b5" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -@pytest.mark.parametrize( - ("snapshots", "expected"), - [ - ([{"first"}, {"first", "second"}, {"first"}], True), - ([{"first"}, {"first", "second"}], False), - ([{"first", "second"}, {"first"}], False), - ], -) -def test_presence_requires_original_handler_membership_sequence( - snapshots: list[set[str]], *, expected: bool -) -> None: - module = presence_membership - assert ( - module.observed_membership(snapshots, {"first"}, {"first", "second"}) - is expected - ) - - -def test_contract_postgres_feature_matches_shared_source() -> None: - feature = ROOT / "features" / "contract" / "realtime-postgres.feature" - expected = "794c2ecbb94fd262a37840f4c3fe3bd9f9ee58c22fda9df2a46de60f93e52c91" - assert hashlib.sha256(feature.read_bytes()).hexdigest() == expected - - -def _wrong_change(event: PostgresChange, field: str) -> PostgresChange: - if field == "record": - return replace(event, record={"id": "wrong-value"}) - if field == "id": - return replace(event, id="wrong-value") - if field == "table": - return replace(event, table="wrong-value") - if field == "mode": - return replace(event, mode="lightweight") - raise ValueError(field) - - -@pytest.mark.parametrize( - ("automatic", "wrong_field"), - [(True, "record"), (False, "id"), (True, "table"), (True, "id"), (True, "mode")], -) -def test_postgres_notification_checks_reject_wrong_identity( - *, automatic: bool, wrong_field: str -) -> None: - row: dict[str, JSONValue] = {"id": "row", "value": "inserted", "owner_id": "user"} - event = PostgresChange( - type="INSERT", - schema="public", - table="records", - timestamp="2026-09-18T12:00:00Z", - record=row if automatic else None, - id=None if automatic else "row", - mode=None if automatic else "lightweight", - ) - postgres_changes.verify_change(event, "INSERT", "records", row, automatic=automatic) - changed = _wrong_change(event, wrong_field) - with pytest.raises(AssertionError): - postgres_changes.verify_change( - changed, "INSERT", "records", row, automatic=automatic - ) - - -@pytest.mark.parametrize("automatic", [True, False]) -def test_postgres_observer_ignores_other_rows( - monkeypatch: pytest.MonkeyPatch, *, automatic: bool -) -> None: - client = VolcanoClient(api_url="https://api.test", anon_key="anon") - channel = client.realtime.channel("contract-postgres") - callbacks: list[Callable[[PostgresChange], None]] = [] - - def on_postgres_changes( - _event: str, - *, - schema: str, - table: str, - callback: Callable[[PostgresChange], None], - ) -> Callable[[], None]: - assert schema == "public" - assert table in {"records", "records_other"} - callbacks.append(callback) - return lambda: None - - monkeypatch.setattr(channel, "on_postgres_changes", on_postgres_changes) - observer = postgres_changes.ChangeObserver(channel, "records", "row") - other = PostgresChange( - type="INSERT", - schema="public", - table="records", - record={"id": "other"} if automatic else None, - id=None if automatic else "other", - ) - for callback in callbacks: - callback(other) - assert not observer.events - assert not observer.inserts - assert not observer.wrong_table - own = PostgresChange( - type="INSERT", - schema="public", - table="records", - record={"id": "row"} if automatic else None, - id=None if automatic else "row", - ) - callbacks[0](own) - callbacks[1](own) - assert asyncio.run(observer.next()) is own - assert observer.inserts == [own] - observer.close() - - -def test_presence_retains_channel_name_at_platform_length_boundary( - monkeypatch: pytest.MonkeyPatch, -) -> None: - stop = RuntimeError("valid channel") - - def channel(name: str, *, channel_type: str) -> FailingPresenceChannel: - assert len(name) <= 64 - assert channel_type == "presence" - return FailingPresenceChannel(stop) - - world = ContractWorld(_fixture()) - world.realtime_channel = "x" * 64 - world.fixture["user_id"] = "user" - other = VolcanoClient(api_url="https://api.test", anon_key="anon") - world.realtime_clients = [world.client, other] - monkeypatch.setattr(world.client.realtime, "channel", channel) - monkeypatch.setattr(other.realtime, "channel", channel) - try: - with pytest.raises(RuntimeError, match="valid channel") as error: - _ = asyncio.run(presence_membership.verify_presence_membership(world)) - finally: - world.cleanup() - assert error.value is stop diff --git a/src/volcano_sdk/_tests/typing/contract_steps.py b/src/volcano_sdk/_tests/typing/contract_steps.py deleted file mode 100644 index 385f51e0..00000000 --- a/src/volcano_sdk/_tests/typing/contract_steps.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Behave registration preserves the argument types of each step.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, assert_type - -from behave import given, then, when - -if TYPE_CHECKING: - from behave.runner import Context - - -@given("a typed count {count:d}") -@when("the typed count becomes {count:d}") -@then("the typed count is {count:d}") -def counted_step(context: Context, count: int) -> None: - context.count = count - - -def check_step_types(context: Context) -> None: - assert_type(counted_step(context, 1), None) - counted_step(context, "invalid") # type: ignore[arg-type] # pyright: ignore[reportArgumentType] - counted_step("invalid", 1) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] diff --git a/tests/fixtures/sdk-contract-dry-run.json b/tests/fixtures/sdk-contract-dry-run.json deleted file mode 100644 index 3160df69..00000000 --- a/tests/fixtures/sdk-contract-dry-run.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "api_url": "http://127.0.0.1:8000", - "project_id": "dry-run-project", - "anon_key": "dry-run-anon-key", - "service_key": "dry-run-service-key", - "platform_token": "dry-run-platform-token", - "user_id": "dry-run-user", - "user_email": "dry-run@example.com", - "user_password": "dry-run-password", - "database_name": "dry_run_database", - "table_name": "dry_run_table", - "query_table_name": "dry_run_query_table", - "fixture_row": { - "slug": "dry-run", - "value": "dry-run" - }, - "mutation_rows": { - "insert": { - "slug": "contract-insert", - "value": "insert" - }, - "update": { - "before": { - "slug": "contract-update", - "value": "before" - }, - "after": { - "slug": "contract-update", - "value": "after" - } - }, - "delete": { - "slug": "contract-delete", - "value": "delete" - } - }, - "bucket_name": "dry-run-bucket", - "storage_path": "dry-run/payload.txt", - "realtime_channel": "dry-run-channel", - "lock_key": "dry-run-lock", - "function_name": "dry-run-function", - "durable_function_name": "dry-run-durable", - "logs_access_token": "dry-run-project-token", - "function_id": "dry-run-function-id", - "realtime_table_name": "sdk_contract_dryrun_rt" -} diff --git a/typings/behave.pyi b/typings/behave.pyi deleted file mode 100644 index 92d690f6..00000000 --- a/typings/behave.pyi +++ /dev/null @@ -1,25 +0,0 @@ -from collections.abc import Callable -from typing import Concatenate, ParamSpec - -from behave.runner import Context - -_P = ParamSpec("_P") - -def given( - step_text: str, **kwargs: object -) -> Callable[ - [Callable[Concatenate[Context, _P], None]], - Callable[Concatenate[Context, _P], None], -]: ... -def when( - step_text: str, **kwargs: object -) -> Callable[ - [Callable[Concatenate[Context, _P], None]], - Callable[Concatenate[Context, _P], None], -]: ... -def then( - step_text: str, **kwargs: object -) -> Callable[ - [Callable[Concatenate[Context, _P], None]], - Callable[Concatenate[Context, _P], None], -]: ... diff --git a/uv.lock b/uv.lock index 2212ea41..4dd4b2fb 100644 --- a/uv.lock +++ b/uv.lock @@ -159,23 +159,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/84/c1e1e845d0453253a98d1ba188d68597f0ba7e552eeabea514d9395d418a/basedpyright-1.40.1-py3-none-any.whl", hash = "sha256:222dc0382caf9816eb23a27cb7059fa96356ddc500b3b7b306072848fd650244", size = 13689276, upload-time = "2026-09-10T23:17:20.322Z" }, ] -[[package]] -name = "behave" -version = "1.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama" }, - { name = "cucumber-expressions" }, - { name = "cucumber-tag-expressions" }, - { name = "parse" }, - { name = "parse-type" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/62/51/f37442fe648b3e35ecf69bee803fa6db3f74c5b46d6c882d0bc5654185a2/behave-1.3.3.tar.gz", hash = "sha256:2b8f4b64ed2ea756a5a2a73e23defc1c4631e9e724c499e46661778453ebaf51", size = 892639, upload-time = "2025-09-04T12:12:02.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/63/71/06f74ffed6d74525c5cd6677c97bd2df0b7649e47a249cf6a0c2038083b2/behave-1.3.3-py2.py3-none-any.whl", hash = "sha256:89bdb62af8fb9f147ce245736a5de69f025e5edfb66f1fbe16c5007493f842c0", size = 223594, upload-time = "2025-09-04T12:12:00.3Z" }, -] - [[package]] name = "boolean-py" version = "5.0" @@ -546,24 +529,6 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] -[[package]] -name = "cucumber-expressions" -version = "20.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/a3/001d7725688d5f8ae7d73d746457c9d11b851a4bad3a315dc7762144ec96/cucumber_expressions-20.1.0.tar.gz", hash = "sha256:0d216ec26e36c71b3e5643f2e72c41f9b266ef04eaa0c7e47a6e3b2caf523b1a", size = 13826, upload-time = "2026-08-05T20:16:52.542Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/b1/fba2393968001b2307facb76e0bc47be5c67185df752a8f5b61926d26760/cucumber_expressions-20.1.0-py3-none-any.whl", hash = "sha256:640782ebaef82313dc64e4684d0e5c5efbab0611b23f0cfad64f90c7041cf73d", size = 20230, upload-time = "2026-08-05T20:16:51.565Z" }, -] - -[[package]] -name = "cucumber-tag-expressions" -version = "11.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/e0/c2741558040293465d615a4f2555e9180c54a559119b96ff0251dda5fa90/cucumber_tag_expressions-11.0.1.tar.gz", hash = "sha256:f8304dd16e546517816e62ace6c486575023812f42e8a60526fcec1694016146", size = 8635, upload-time = "2026-08-05T20:33:20.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/2a/894aded5804c76cf148965721cb57fed0d923ddb2747a77c9147f20d58a9/cucumber_tag_expressions-11.0.1-py3-none-any.whl", hash = "sha256:8ee5433a3b1ad16ca607c905fa3bb6d85d57f087ba119b14ea5e82cd35ea98c5", size = 9757, upload-time = "2026-08-05T20:33:19.95Z" }, -] - [[package]] name = "cyclonedx-python-lib" version = "11.12.0" @@ -1318,28 +1283,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] -[[package]] -name = "parse" -version = "1.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/f2/0b504486c2a5564798607d3860e48ed19c6443d5e9cc3ec61cc6b8b4ef58/parse-1.22.1.tar.gz", hash = "sha256:d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911", size = 36970, upload-time = "2026-05-26T03:44:52.624Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/c5/7c16e99869e1f422629092cfd23e3b58e461988c3f9c36fd3624bb4142e6/parse-1.22.1-py2.py3-none-any.whl", hash = "sha256:20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32", size = 20925, upload-time = "2026-05-26T03:44:51.156Z" }, -] - -[[package]] -name = "parse-type" -version = "0.6.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parse" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, -] - [[package]] name = "pastel" version = "0.2.1" @@ -2114,15 +2057,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] -[[package]] -name = "types-behave" -version = "1.3.0.20260408" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/ed/444978d0f7f4314bf0dd40e36127526cade4e870b87c61df8f6223a396ce/types_behave-1.3.0.20260408.tar.gz", hash = "sha256:3e57e06db090a36b90f96f12dacb5eb346613a1ec5c3c3d7d46df5c733f15866", size = 7868, upload-time = "2026-04-08T04:28:41.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/dd/d459dc8d2bef739d8ccb163b6e3380fc2952157b0b6367faa8d9d18b9e21/types_behave-1.3.0.20260408-py3-none-any.whl", hash = "sha256:822379f7ababf7376f2efb867365056eaaf7a6d6567f3e11bd5ed4642ae51ead", size = 9032, upload-time = "2026-04-08T04:28:41.215Z" }, -] - [[package]] name = "typing-extensions" version = "4.16.0" @@ -2191,7 +2125,6 @@ build = [ dev = [ { name = "aws-durable-execution-sdk-python-testing" }, { name = "basedpyright" }, - { name = "behave" }, { name = "coverage" }, { name = "hypothesis" }, { name = "mutmut" }, @@ -2207,7 +2140,6 @@ dev = [ { name = "pytest-socket" }, { name = "ruff" }, { name = "tox" }, - { name = "types-behave" }, { name = "volcano-sdk-python", extra = ["durable"] }, ] @@ -2226,7 +2158,6 @@ build = [{ name = "hatchling", specifier = "==1.32.4" }] dev = [ { name = "aws-durable-execution-sdk-python-testing", specifier = ">=1.2.1,<2.0.0" }, { name = "basedpyright", specifier = "==1.40.1" }, - { name = "behave", specifier = ">=1.3.3,<2.0.0" }, { name = "coverage", specifier = "==7.16.1" }, { name = "hypothesis", specifier = "==6.168.0" }, { name = "mutmut", specifier = "==3.8.0" }, @@ -2242,7 +2173,6 @@ dev = [ { name = "pytest-socket", specifier = "==0.8.1" }, { name = "ruff", specifier = "==0.16.4" }, { name = "tox", specifier = "==4.62.0" }, - { name = "types-behave", specifier = "==1.3.0.20260408" }, { name = "volcano-sdk-python", extras = ["durable"] }, ]