diff --git a/.github/workflows/lint-docs.yml b/.github/workflows/lint-docs.yml deleted file mode 100644 index 460a7a77..00000000 --- a/.github/workflows/lint-docs.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: Lint docs - -on: - pull_request: - paths: - - 'docs/**' - - '.github/workflows/lint-docs.yml' - -permissions: - contents: read - -jobs: - docs: - uses: Kong/volcano-docs/.github/workflows/lint-docs.yml@2eed9e93cae0b070f57788e7c270ccfbb4cd4579 - with: - path: docs diff --git a/AGENTS.md b/AGENTS.md index e82e36b1..8aeeec52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,4 +11,6 @@ automation. - 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. +- Keep maintainer guidance under `maintainers/`. +- Public SDK docs live in `Kong/volcano-docs` under `content/sdk/python`. When + user-facing behavior changes, open a companion PR there and link the two PRs. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f200ab4f..7f8ccab8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,8 +94,9 @@ actual live results separately from discovery or package checks. ### Documentation and release boundaries -`docs/` is published to the developer documentation site. Put user-facing -examples there and maintainer instructions in this file or beside the code. +Public guides live in `Kong/volcano-docs` under `content/sdk/python` and +publish to https://docs.volcano.dev/sdk/python. Put user-facing examples there +in a companion PR, and maintainer instructions in this file or beside the code. Keep equivalent language examples current in the same coordinated change. Package checks validate artifacts; they do not authorize publication. Treat registry-installed quickstarts and release approval as separate release work. diff --git a/README.md b/README.md index bf6a0168..538ff0e0 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@ Use the Volcano Python SDK to access authentication, databases, storage, functions, locks, logs, and realtime events. Requires Python 3.11 or later. -Start with the [Python quickstart](https://github.com/Kong/volcano-sdk-python/blob/main/docs/README.md). -See the [functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md) for invocation identity and response handling. +Start with the [Python quickstart](https://docs.volcano.dev/sdk/python). +See the [functions guide](https://docs.volcano.dev/sdk/python/functions) for invocation identity and response handling. -See [Authentication](https://github.com/Kong/volcano-sdk-python/blob/main/docs/authentication.md) for account, session, email, and OAuth workflows. +See [Authentication](https://docs.volcano.dev/sdk/python/authentication) for account, session, email, and OAuth workflows. ## Install @@ -199,7 +199,7 @@ Acquisition accepts caller-owned UUID `token` and `request_id` values and retrie an ambiguous transport failure or HTTP 503 once with the same request and credential. Retain those IDs to recover an uncertain acquisition. Other lock methods accept `request_id`; block-scoped helpers forward initial IDs only to acquisition. -See the [lock guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/locks.md) +See the [lock guide](https://docs.volcano.dev/sdk/python/locks) for examples and fencing requirements. `locks.get()` returns immutable lock availability, expiry, and fencing-token @@ -282,7 +282,7 @@ Repeating the same decision returns the approval unchanged; a conflicting one, o deciding an approval that expired or whose execution ended, raises `ConflictError` with `code` set to `approval_decided`, `approval_expired`, or `approval_cancelled`. See the -[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#decide-approvals-from-a-backend). +[functions guide](https://docs.volcano.dev/sdk/python/functions#decide-approvals-from-a-backend). `durable.messages` sends messages to a running execution, lists its messages, and follows what it publishes. It takes the credential `durable.start()` takes; @@ -316,7 +316,7 @@ visible to the session, leaves the channel and is reported once to the event loop's exception handler. A send to an execution that has ended raises `ConflictError` with `code` set to `execution_not_running`. Pass `project_id` to use the owner's project route instead. See the -[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#send-and-follow-messages). +[functions guide](https://docs.volcano.dev/sdk/python/functions#send-and-follow-messages). ## Write a durable function @@ -453,14 +453,14 @@ sets `VOLCANO_PLATFORM_API_URL` on durable functions; without it the call raises `RuntimeError`. The function retries registering the approval for up to 30 seconds. If Volcano refuses it, the call raises the durable runtime's `CallbackSubmitterError` with the SDK error's message. See the -[functions guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/functions.md#wait-for-an-approval). +[functions guide](https://docs.volcano.dev/sdk/python/functions#wait-for-an-approval). `logs.search()` returns an immutable page of retained runtime or deployment log events. Pass `next_cursor` back as `cursor` to continue a search. `logs.activity()` returns immutable time buckets using the same resource selector and query syntax. Both methods require a platform user token or a project access token. A `read_only` project token is sufficient; end-user sessions cannot read project logs. -See the [logs guide](https://github.com/Kong/volcano-sdk-python/blob/main/docs/logs.md). +See the [logs guide](https://docs.volcano.dev/sdk/python/logs). Database selects, inserts, updates, deletes, log reads, and authenticated storage requests (including upload sessions and parts) refresh the captured @@ -845,7 +845,7 @@ Without a refresh token, `refresh_session()` raises `AuthenticationError` and `sign_out()` clears local state and revokes the server session when the access JWT contains a readable UUID `session_id`. Supplied credentials require both a refresh token and an access JWT with a readable UUID -`session_id` to enable refresh. See the [token bootstrap example](https://github.com/Kong/volcano-sdk-python/blob/main/docs/README.md#use-a-supplied-access-token). +`session_id` to enable refresh. See the [token bootstrap example](https://docs.volcano.dev/sdk/python#use-a-supplied-access-token). Copy a complete native session into another client's memory: diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 4c1d5c97..00000000 --- a/docs/README.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -title: Python SDK -description: Install the Volcano Python SDK and authenticate a user from a Python application. -order: 1 ---- - -Use `volcano-sdk-python` for authentication, database queries, storage, functions, logs, locks, and realtime events. -REST calls are synchronous and raise typed exceptions on failure. -Python 3.11 or later is required; CI tests every Python version from 3.11 through 3.14. - -## Install the published package - -```bash -python -m venv .venv -source .venv/bin/activate -python -m pip install volcano-sdk-python -``` - -The [PyPI distribution](https://pypi.org/project/volcano-sdk-python/) is named `volcano-sdk-python`. -Import it as `volcano_sdk`. -The package includes type information for mypy and other PEP 561-compatible checkers. - -## Sign in and read a profile - -Create a project, enable [email and password authentication](/platform/authentication/configuring-auth-methods), and create a user with a confirmed email when your project requires confirmation. -Use that project's [anonymous key](/platform/authentication/security/anon-keys). -Set `VOLCANO_ANON_KEY`, `VOLCANO_USER_EMAIL`, and `VOLCANO_USER_PASSWORD` in your environment. -Set `VOLCANO_API_URL` only when using a different API endpoint, such as local mode. - -Save this as `quickstart.py`: - -```python -import os - -from volcano_sdk import VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), -) -session = client.auth.sign_in( - email=os.environ["VOLCANO_USER_EMAIL"], - password=os.environ["VOLCANO_USER_PASSWORD"], -) -try: - user = client.auth.get_user() - assert user.id == session.user_id - print(f"Signed in as {user.email}") -finally: - client.auth.sign_out() -``` - -Run it with `python quickstart.py`. -It signs in, fetches the server-validated profile, prints the user's email, revokes its server session, and clears the local session. -An invalid email or password raises `AuthenticationError`; failed network requests raise `TransportError`. -Both inherit from `VolcanoError` and are exported from `volcano_sdk`. - -## Keep authentication scoped to one user - -A client holds its current session in memory. -Use a separate client for each independent user session; do not share one mutable client across users in a web server. -`client.current_session` and `client.auth.get_session()` read the local immutable snapshot without a request. -Use `client.auth.get_user()` when you need a server-validated profile. -Successful profile operations update the cached user while preserving the current credentials. -Authenticated profile, session-list/deletion, email-change request/cancellation, linked-provider, provider-token, and provider-API operations refresh a usable session once after HTTP 401 and replay the original request values. -Deleting the current server session also clears its refreshed local credentials; a separately adopted session remains current. -These operations do not retry other HTTP failures or ambiguous network failures, and they never retry under a replacement session. - -`sign_up()` returns an acknowledgement without signing in by default. -Pass `sign_in_when_allowed=True` to sign in only when the project does not require email confirmation. -To adopt an existing session, pass a `Session` containing its access token, refresh token, and user ID to `client.auth.set_session()`. -The SDK does not persist tokens for you. - -For operations that require a [service key](/platform/authentication/security/service-keys), pass `service_key` to the constructor in trusted server code. -Keep service keys and user credentials out of source control and client applications. - -## Use a supplied access token - -For a server request that already carries a user's access token, create a client for that request: - -```python -import os - -from volcano_sdk import VolcanoClient - - -def load_request_user(access_token: str): - client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - access_token=access_token, - ) - return client.auth.get_user() -``` - -Call this helper from your request handler with the bearer token from that request. -For a Volcano function, use the access token in `event["__volcano_auth"]["access_token"]` supplied for that invocation. -The helper validates the token with Volcano before returning the user. - -Once a user identity has been validated, a refresh response for another user is rejected and leaves the current credentials unchanged. -Construction makes no request and does not persist credentials. -The initial snapshot has `refresh_token=None`, `user_id=None`, and `user=None`. -A successful profile read fills in the validated identity and cached user while retaining the supplied access token. -Without a refresh token, an HTTP 401 remains an authentication error, `refresh_session()` raises `AuthenticationError`, and `sign_out()` revokes the server session identified by the access token before clearing local state. -Refresh must preserve the server session identified by the access JWT, even before a profile is loaded. A different session is rejected, including another session for the same user. Supplied credentials need a readable session identifier to refresh, even when you provide a user profile or load it from the server. Profile data does not prove that access and refresh tokens belong together. -For supplied credentials, sign-out revokes the access-token session. On HTTP 401, it can refresh once and revoke that same session without adopting the renewed credentials locally. -When the SDK received both credentials together from sign-in or a validated refresh, it uses the refresh token directly, even if access has expired. -Sign-out joins an existing refresh and prevents later refresh attempts for that session. Concurrent sign-outs share one result; a separate sign-in or adoption remains current. -Pass `refresh_token` alongside `access_token` when the client should refresh that session. -A revocation failure is reported after local clearing; it does not prove that copied tokens are invalid. -Adopting a session with `set_session()` still requires complete credentials and identity. - -## Use the rest of the API - -The SDK repository contains [examples for every public facade](https://github.com/Kong/volcano-sdk-python#try-the-contract-facade), including database filters and mutations, resumable uploads, function invocation, log queries, lock guards, and realtime presence and database changes. -Realtime uses an asynchronous lifecycle; follow those examples to connect and disconnect its channels. - -See [release notes](https://github.com/Kong/volcano-sdk-python/releases) for version changes and [GitHub issues](https://github.com/Kong/volcano-sdk-python/issues) to report a problem. -Include the package version, Python version, and a minimal reproduction without credentials. - -See [Distributed locks](./locks.md) for acquisition recovery, renewal, and fencing. - -Read the [database guide](./database.md) for projection, filters, ordered pagination, and mutations. - -For uploads, visibility, and resumable sessions, see [Storage](./storage.md). - -See [Logs](./logs.md) for project-token authentication, search, pagination, and activity. - - -See [Realtime](./realtime.md) for broadcasts, presence, database changes, and shutdown. - -See [Authentication](./authentication.md) for account, session, email, and OAuth workflows. - -See [Functions](./functions.md) for standard invocation, durable execution, local durable development, and error handling. - -See [Versions and compatibility](./versions.md) for runtime support, upgrades, and restoring a tested dependency set. - -- [Sandboxes](sandboxes.md): isolated commands, sessions, files, and HTTP access. diff --git a/docs/authentication.md b/docs/authentication.md deleted file mode 100644 index e94e3011..00000000 --- a/docs/authentication.md +++ /dev/null @@ -1,453 +0,0 @@ ---- -title: Authentication -description: Manage Volcano user accounts, sessions, email flows, and OAuth from Python. -order: 2 ---- - -Sign in with a project's anonymous key and an existing user's credentials. -Enable the required [authentication methods](/platform/authentication/configuring-auth-methods) for the project first. -Install the SDK using the [quickstart](./README.md). - -```python -import os - -from volcano_sdk import VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), -) -session = client.auth.sign_in( - email=os.environ["VOLCANO_USER_EMAIL"], - password=os.environ["VOLCANO_USER_PASSWORD"], -) -user = client.auth.get_user() -assert user.id == session.user_id -print(user.email) -``` - -Use a separate client for each independent user session. -The examples below describe separate account workflows using this client. -Keep access and refresh tokens out of logs; the SDK stores sessions in memory only. - -## Create an account - -`sign_up()` returns an immutable acknowledgement without changing the session by default. -The signup acknowledgement is identical for new and existing email addresses. Pass -`sign_in_when_allowed=True` to follow it with `sign_in()` only when confirmation is not required: - -```python -result = client.auth.sign_up( - email="new-user@example.com", - password="correct-horse-battery-staple", - sign_in_when_allowed=True, -) -session = result.session # None when no follow-up sign-in ran. -``` - -A successful follow-up stores the session and emits the normal sign-in event. A failed -follow-up raises its usual typed error; it does not undo the successful signup. - -## Read the local session and user - -`get_session()` reads immutable local state. It does not refresh or validate the token. - -Sessions returned by authentication retain the user payload in `session.user`, -including metadata. The snapshot is deeply immutable and available without a -request. It is cached data, not proof of authentication; use `auth.get_user()` -to fetch the server-validated profile. Existing three-field `Session` construction -still works, with `user=None`. An adopted snapshot must have the same user ID. -Successful `get_user()`, `update_user()`, `convert_anonymous()`, and -`confirm_email_change()` calls update that local snapshot. Automatic HTTP 401 recovery -can rotate credentials and emit `TOKEN_REFRESHED`; the profile update itself does not. Previously returned sessions remain immutable. -Profile identity checks compare UUID values; the session retains its original -user ID spelling, including in the cached snapshot. - -## Validate and update a profile - -`get_user()` sends the active access token to Volcano and returns an immutable, server-validated -profile with the complete public AuthUser fields. Profile timestamps are timezone-aware `datetime` -values, and nested user and application metadata are immutable. The request updates the cached -profile without changing credentials unless HTTP 401 recovery requires a refresh. -Successful recovery rotates credentials and emits `TOKEN_REFRESHED`. If another authentication operation replaces the session -while the request is in flight, `get_user()` raises `SessionChangedError` instead of returning a -profile for stale credentials. - -```python -user = client.auth.update_user(metadata={"display_name": "Ada", "avatar": None}) -print(user.id) -``` - -`update_user()` updates the current user's password, metadata, or both. Metadata is a shallow patch: -omitted keys remain unchanged, and setting a key to `None` removes it. The method returns the same -immutable profile type as `get_user()` and updates the cached profile without changing credentials unless HTTP 401 recovery requires a refresh. -Successful recovery rotates credentials and emits `TOKEN_REFRESHED`. -It also rejects a response if another authentication operation replaces the session while the -update is in flight. - -## Recover a password - -Request a password reset email without creating or changing a session: - -```python -client.auth.reset_password_for_email(email="user@example.com") -``` - -When transactional email is configured, Volcano sends the reset link. Success returns `None`, and -the response is intentionally identical whether or not the email belongs to an account. Failures -raise the same typed Volcano errors as other authentication operations. - -## Confirm an email address - -Confirm an email address with the token from its confirmation link: - -```python -client.auth.confirm_email(token="confirmation-token") -``` - -Success returns `None`. Confirmation does not sign in the confirmed account or change an unrelated -local session. - -## Resend confirmation - -Request another confirmation email without revealing account state: - -```python -client.auth.resend_confirmation(email="user@example.com") -``` - -Success returns `None` whether the account is unknown, already confirmed, or eligible. Volcano sends -mail only for an existing unconfirmed account when transactional email is configured. Rate limits -raise `RateLimitedError` with `retry_after` when the server supplies it. - -## Change an email address - -Request confirmation for a new email address while keeping the current session: - -```python -result = client.auth.request_email_change(new_email="new@example.com") -print(result.new_email) -``` - -The immutable result contains the server acknowledgement. Its `message` and `new_email` fields may -be `None`. The request fails if there is no active session or that session changes in flight. - -Cancel the pending change while keeping the current session: - -```python -client.auth.cancel_email_change() -``` - -Success returns `None`. A successful stale response is rejected if another authentication operation -replaces the session while cancellation is in flight. - -Confirm the pending change with the token delivered to the new address: - -```python -user = client.auth.confirm_email_change(token="email-change-token") -print(user.email) -``` - -The method returns the immutable updated user without replacing the active session. A successful -stale response is rejected if another authentication operation replaces that session in flight. - -## List server sessions - -List sessions using the stable activity-ordered offset pagination: - -```python -page = client.auth.list_sessions(page=1, limit=20) -for session in page.sessions: - print(session.id, session.user_agent, session.is_current) -``` - -The method returns immutable `SessionPage` and `AuthSession` values. It raises -`SessionChangedError` instead of returning a page for a session that was replaced while the request -was in flight. Sort, filter, and cursor controls are not yet exposed by this facade. - -## Use hosted authentication - -Build a managed hosted-auth URL: - -```python -import secrets - -hosted_state = secrets.token_urlsafe(32) -hosted_url = client.auth.get_hosted_auth_url( - project_id="00000000-0000-4000-8000-000000000020", - action="signup", - state=hosted_state, -) -``` - -Store `hosted_state` in the user's signed server-side session before redirecting to `hosted_url`. -In the callback, atomically fetch and delete the stored state before validation, -even if validation or adoption fails. Reject a missing or already-consumed state. -After parsing the returned fragment into a `Session`, validate and adopt it: - -```python -session = client.auth.adopt_hosted_auth_session( - returned_session, - state=returned_state, - expected_state=hosted_state, -) -``` - -The SDK rejects a mismatched state before changing local authentication. It builds and adopts the -flow but does not parse browser URLs, navigate, or persist state. The `action` deep link applies to -Volcano's built-in page; a customized login page must implement its own signup or forgot-password -flow. - -## Sign in with OAuth - -Build the URL that starts an OAuth sign-in flow: - -```python -import secrets - -oauth_state = secrets.token_urlsafe(32) -authorization_url = client.auth.sign_in_with_oauth( - provider="github", - redirect_to="https://app.example.com/auth/callback", - state=oauth_state, -) -``` - -Store `oauth_state` in the user's signed server-side session, then redirect the user to the returned -URL. In the callback, atomically fetch and delete the stored nonce as `stored_oauth_state`; -reject a missing or already-consumed nonce. Pass the returned and consumed states to -the SDK so it rejects login CSRF before exchanging the one-time code: - -```python -session = client.auth.exchange_oauth_code( - code=callback_code, - redirect_to="https://app.example.com/auth/callback", - state=callback_state, - expected_state=stored_oauth_state, -) -``` - -The callback URL must exactly match a registered project redirect. The exchange stores the returned -Volcano session on the client. The SDK does not open a browser or persist OAuth state between -requests; use your framework's signed session or equivalent storage for that state. - -List the OAuth providers linked to the current account: - -```python -providers = client.auth.list_linked_oauth_providers() -for provider in providers: - print(provider.provider, provider.linked_at) -``` - -The method returns an immutable tuple of `LinkedOAuthProvider` values and raises -`SessionChangedError` if the active session changes while the request is in flight. - -## Link and unlink providers - -Start linking another OAuth provider to the current account: - -```python -authorization_url = client.auth.link_oauth_provider(provider="github") -``` - -Redirect the user to the returned URL to complete the provider flow. The method accepts `apple`, -`github`, `google`, or `microsoft` and raises `SessionChangedError` if the active session changes -while the request is in flight. - -Unlink an OAuth provider from the current account: - -```python -client.auth.unlink_oauth_provider(provider="github") -``` - -The server rejects removal of the account's only authentication method. A successful stale response -raises `SessionChangedError` instead of acknowledging work authorized by a replaced session. - -## Use provider APIs - -Check whether Volcano has a valid server-held provider token: - -```python -status = client.auth.get_oauth_provider_token(provider="github") -print(status.provider, status.expires_in) -``` - -The immutable `OAuthProviderTokenStatus` contains provider and expiry metadata, not the credential. -Volcano refreshes an expired token on the server. A stale result raises `SessionChangedError`. - -Refresh a provider token explicitly: - -```python -status = client.auth.refresh_oauth_provider_token(provider="github") -print(status.provider, status.expires_in) -``` - -The refresh credential and new access token remain on the server. A stale result raises -`SessionChangedError`. - -Call a provider API through Volcano's fixed-host server proxy: - -```python -repos = client.auth.call_oauth_api( - provider="github", - endpoint="/user/repos", -) -print(repos[0]["name"]) -``` - -The method returns an immutable copy of the provider's JSON value. Volcano owns token refresh and -host validation. A stale result raises `SessionChangedError`. - -## Revoke other sessions - -Sign out every other device while keeping the current session active: - -```python -client.auth.delete_all_other_sessions() -``` - -Success returns `None`. Do not replace the client's session while this request is in flight: the -server may revoke that replacement as an "other" session. If replacement occurs, the method raises -`SessionChangedError` instead of acknowledging a stale result. - -## Revoke one session - -Revoke one session by ID: - -```python -client.auth.delete_session(session_id="00000000-0000-4000-8000-000000000099") -``` - -The request uses the current access token. When its JWT contains a readable UUID `session_id`, -deleting that session clears local credentials even if the request outcome is uncertain. -Without that identifier, the SDK cannot recognize self-deletion. Deleting another session does not -itself clear local state. HTTP 401 recovery can rotate credentials and emit `TOKEN_REFRESHED`; -a server-rejected refresh clears the captured session before the operation raises. If another authentication operation replaces the session before deletion finishes, the method -raises `SessionChangedError` instead of clearing the replacement or acknowledging a stale result. - -## Use anonymous accounts - -Create an anonymous account and make its tokens the current session: - -```python -session = client.auth.sign_in_anonymously(metadata={"device": "mobile"}) -``` - -Anonymous sign-ins must be enabled for the project. Convert the account before signing out if the -user needs to recover it later. - -Attach email credentials while preserving the anonymous user's ID: - -```python -user = client.auth.convert_anonymous( - email="user@example.com", - password="a-long-example-password-2026", - metadata={"display_name": "Ada"}, -) -``` - -When email confirmation is required, confirm the new address before treating it as verified. - -## Complete a password reset - -Set a new password with the recovery token from that email: - -```python -client.auth.reset_password( - token="recovery-token", - new_password="new-correct-horse-battery-staple", -) -``` - -Success returns `None`. The reset revokes the recovered account's existing sessions and does not -sign it in. The client keeps any unrelated local session unchanged; sign in with the new password -when the reset flow completes. - -## Use supplied credentials - -To start with only a supplied user access token, pass `access_token` to -`VolcanoClient`. Construction makes no request and leaves `refresh_token`, -`user_id`, and `user` as `None` until supplied or validated by the server. -`get_user()` validates and caches the profile without changing credentials unless HTTP 401 recovery requires a refresh. -Successful recovery rotates credentials and emits `TOKEN_REFRESHED`. -Without a refresh token, `refresh_session()` raises `AuthenticationError` and -`sign_out()` clears local state and revokes the server session when the access JWT -contains a readable UUID `session_id`. -Supplied credentials require both a refresh token and an access JWT with a readable UUID -`session_id` to enable refresh. See the [token bootstrap example](./README.md#use-a-supplied-access-token). - -## Adopt an existing session - -Copy a complete native session into another client's memory: - -```python -session = source.auth.get_session() -if session is not None: - fresh.auth.set_session(session) -``` - -`set_session()` copies the session without making a request, persisting credentials, or notifying -auth-state subscribers. It raises `ValueError` when the session type or any credential field is -incomplete. - -Password sign-in raises `SessionChangedError` if local session state changes while the request is -in flight. The late response does not replace the newer state or emit a sign-in notification. - -## Refresh the current session - -Refresh the session with its current refresh token: - -```python -refreshed = client.auth.refresh_session() -assert client.auth.get_session() is refreshed -``` - -On success, `refresh_session()` replaces the in-memory session and returns the immutable new -snapshot. An authentication rejection from the refresh endpoint clears the captured session. -Missing refresh credentials, failed session-continuity checks, server errors, and -transport failures preserve it, and a late response never replaces a newer session. The SDK does -not persist sessions. - -## Observe authentication changes - -Observe local session transitions: - -```python -from volcano_sdk import AuthChangeEvent, Session - - -def handle_auth_change(event: AuthChangeEvent, session: Session | None) -> None: - print(event, session is not None) - - -subscription = client.auth.on_auth_state_change(handle_auth_change) -# Later, stop receiving events. -subscription.unsubscribe() -``` - -Registration queues `INITIAL_SESSION`. It normally arrives before registration returns, but an -existing notification dispatch may deliver it afterward. Successful session creation, refresh, and -local clearing emit `SIGNED_IN`, `TOKEN_REFRESHED`, and `SIGNED_OUT`. Callbacks are delivered locally -in transition order after the state lock is released. Ordinary callback `Exception` failures are -isolated; exceptions such as `KeyboardInterrupt` propagate after the session transition has committed. Unsubscribing prevents queued and future delivery; a callback already selected for -delivery may finish after `unsubscribe()` returns. The SDK does not broadcast between processes or -persist sessions. - -## Sign out the current session - -Sign out by revoking and clearing the current session: - -```python -client.auth.sign_out() -assert client.auth.get_session() is None -``` - -Sign-out uses the refresh token directly when the SDK received both credentials together from -sign-in or a validated refresh. Supplied credentials use the access-token session when its JWT -contains a readable UUID `session_id`; on HTTP 401, the SDK can refresh once and revoke that -same session without adopting the renewed credentials. Without that identifier, sign-out uses -the supplied refresh token, or only clears local state if no refresh token is available. -Calling `sign_out()` without a session succeeds without a request. A revocation failure is raised -after the captured local session is cleared. Sign-out waits for an already-running refresh and uses its validated credentials. -Later refresh attempts raise `SessionChangedError` without a request. Concurrent sign-out calls -share one result. A separate sign-in or adoption remains current. diff --git a/docs/database.md b/docs/database.md deleted file mode 100644 index f9f3da30..00000000 --- a/docs/database.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Database queries -description: Read and change Postgres rows through the Python SDK with filters, ordering, and pagination. -order: 3 ---- - -Query an existing database and table using the signed-in user's permissions. -This example expects a `main` database with an `items` table containing `id`, `name`, and `status` columns, plus Row-Level Security policies that permit the user to read those rows. -Set the credentials described in the [quickstart](./README.md). - -```python -import os - -from volcano_sdk import VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), -) -client.auth.sign_in( - email=os.environ["VOLCANO_USER_EMAIL"], - password=os.environ["VOLCANO_USER_PASSWORD"], -) -items = client.database("main").from_("items") -rows = items.select("id", "name").eq("status", "published").execute() -for row in rows: - print(row["id"], row["name"]) -``` - -`execute()` returns a list of row dictionaries, including an empty list when no rows match. -Builders are immutable; each chained operation returns a new builder, so a base query can be reused. -The following examples reuse an authenticated `client` and the same table. - -## Filter, sort, and page through rows - -```python -base = client.database("main").from_("items").select("id", "name") -rows = ( - base.ilike("name", "%volcano%") - .in_("status", ["draft", "published"]) - .order("name") - .order("id") - .limit(20) - .offset(40) - .execute() -) -``` - -| Methods | Filter | -| --- | --- | -| `eq`, `neq` | Equal or not equal to a value | -| `gt`, `gte`, `lt`, `lte` | Compare values | -| `like`, `ilike` | Case-sensitive or case-insensitive SQL pattern; `%` matches any sequence | -| `is_("deleted_at", None)` | Match a SQL null | -| `is_("enabled", True)` | Match a SQL boolean | -| `in_("status", ["draft", "published"])` | Match a member of a list | - -Use `select("*")` to return every column. -Pass `ascending=False` to `order()` for descending order. -Include a unique final sort column such as `id` when paging with `limit()` and `offset()`. - -## Insert, update, and delete - -```python -items = client.database("main").from_("items") -created = items.insert({"name": "Volcano", "status": "draft"}).execute() -item_id = created[0]["id"] -updated = items.update({"status": "published"}).eq("id", item_id).execute() -deleted = items.delete().eq("id", item_id).execute() -``` - -Mutations return a list of the affected rows. -Updates and deletes with no matches return an empty list. -They require at least one filter; Volcano rejects filterless updates and deletes. - -## Handle authentication failures - -Database reads and mutations refresh a rejected access token once when the captured session has a usable refresh token, then retry the same request. -HTTP 403 responses and network failures do not trigger this retry. -Mutations are never retried after an ambiguous transport failure. -Replacing or signing out the session during recovery raises `SessionChangedError` instead of replaying under another user. - -For direct Postgres connections inside a Volcano function, `database_connection_string()` can derive a connection string from `DATABASE_URL` and a server-validated `user_id` while preserving its database target. -Passing a user ID applies that user's Row-Level Security context; omitting it requests service access. -Keep the resulting connection string private. diff --git a/docs/functions.md b/docs/functions.md deleted file mode 100644 index 787dc1e0..00000000 --- a/docs/functions.md +++ /dev/null @@ -1,376 +0,0 @@ ---- -title: Functions -description: Invoke deployed Volcano functions from Python and inspect their response status, headers, and data. -order: 5 ---- - -Invoke a deployed function by its name. -This example assumes a public function named `hello` that accepts a JSON object. -Grant the anonymous key the explicit `functions.invoke` permission; the default -authentication-only permissions do not allow invocation. See [anonymous keys](/platform/authentication/security/anon-keys). - -```python -import os - -from volcano_sdk import VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), -) -result = client.functions.invoke("hello", {"name": "Ada"}) -print(result.status) -print(result.data) -print(result.version) -``` - -The SDK resolves the function's invocation endpoint and caches it for the lifetime provided by Volcano. Repeated resolution of a missing name raises a new `NotFoundError` with the original message, status, and error code while the cached miss remains valid. -Allow outbound requests to the resolved function domain as well as the API host. -Deployments without a separate function domain use the API invocation endpoint. - -## Choose the invocation identity - -The SDK uses the current session's access token, including a supplied `access_token`, before a configured service key or anonymous key. -A rejected or unsupported session token does not fall back to a key. Use an end-user access token when the function requires a user identity. -Use the [quickstart](./README.md) to sign in before invoking a function that requires a user. -An invocation authorized only by the anonymous key has no user identity. - -Supply `service_key` to the client constructor for trusted server operations that require it. -Use a separate client for each independent user session. - -## Recover a rejected session - -Function resolution and invocation recover from a platform HTTP 401 before dispatch: -the SDK refreshes the captured session and retries the rejected request once. -Concurrent calls share successful recovery. Replacing or signing out that session -prevents replay under another identity. The call preserves its original payload values. -A function's own response, HTTP 403, or a network failure never triggers this retry. -Anonymous and service keys do not refresh. - -## Read the result - -`FunctionResponse` is immutable and exposes `status`, `headers`, `version`, and `data`. -The payload sent to `invoke()` must be a JSON object; omitting it sends an empty object. -Response data can be a JSON object, array, scalar, text, or `None` for an empty body. -JSON arrays become immutable tuples, and invalid JSON is returned as text. - -A function's own non-success response is returned as a result when Volcano confirms the function ran. -Check `result.status` to handle those application errors. -Non-success platform HTTP responses before dispatch raise typed SDK exceptions such as `NotFoundError` or `AuthenticationError`. - -If a cached function identity no longer exists, the SDK resolves the name again and retries once only when the platform confirms no function was dispatched. -A function's own HTTP 404 does not trigger another invocation. -Network failures do not establish whether a function ran; do not blindly retry operations with side effects. - -Invalid invocation arguments and malformed successful resolution responses can raise `ValueError` or `TypeError`. - -## Start and follow a durable execution - -A durable execution can run for up to 366 days. Starting one returns a handle instead of waiting for its result: - -```python -handle = client.durable.start( - "charge-order", - {"order_id": "order-9"}, - execution_name="order-9", -) - -owner_client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), - access_token=os.environ["VOLCANO_PLATFORM_TOKEN"], -) -execution = owner_client.durable.get(project_id, "charge-order", handle.id) -page = owner_client.durable.list(project_id, "charge-order", status="running") -owner_client.durable.stop(project_id, "charge-order", handle.id) -``` - -`start()` accepts the same active session, service key, or anonymous key as `functions.invoke()`. Besides [sending and following messages](#send-and-follow-messages), it is the only durable operation available to application credentials. An execution name makes a start idempotent. - -`get()`, `list()`, and `stop()` are owner-scoped. Call them from a trusted backend with the project owner's platform user token or a project access token; a `read_only` token can read and list but not stop. Auth-user sessions, anonymous keys, and service keys are not accepted. `stop()` returns after the stop request is accepted, so poll `get()` until `is_terminal` is true. - -## Write a durable function - -Use `volcano_sdk.durable_authoring` in a durable function running on `python3.13` or `python3.14`: - -```python -from volcano_sdk.durable_authoring import durable - - -@durable -def handler(event, ctx): - charge = ctx.step("charge", lambda scope: charge_card(event["order_id"])) - ctx.wait("settle", "30s") - return {"charge_id": charge["id"]} -``` - -Place the handler and its dependency file under the function name: - -```text -volcano/functions/charge-order/ -├── main.py -└── requirements.txt -``` - -```text -# volcano/functions/charge-order/requirements.txt -volcano-sdk-python -``` - -Declare the same name as durable in `volcano-config.yaml`; the decorator does not register the function with the CLI: - -```yaml -version: 1 -project: - name: my-app -functions: - - name: charge-order - kind: durable -``` - -Volcano records each context operation. Resumed executions replay recorded results instead of repeating completed work. Keep changing decisions inside `ctx.step()`. Use `ctx.wait_until()` to poll application state. - -## Wait for an approval - -`ctx.wait_for_approval()` pauses the execution until a person approves or denies it from the dashboard, the CLI, or the [owner client](#decide-approvals-from-a-backend): - -```python -from volcano_sdk.durable_authoring import durable - - -@durable -def handler(event, ctx): - order = ctx.step("load-order", lambda scope: load_order(event["order_id"])) - - decision = ctx.wait_for_approval( - "ship-order", - title=f"Ship order {order['id']}?", - description="Orders over $500 need a second look.", - details={"order_id": order["id"], "total": order["total"]}, - timeout="24h", - ) - - if not decision.approved: - ctx.step("release-stock", lambda scope: release_stock(order["id"])) - return {"shipped": False, "status": decision.status} - - ctx.step("ship", lambda scope: ship_order(order["id"])) - return {"shipped": True, "comment": decision.comment} -``` - -| Argument | Description | -|---|---| -| `name` | Required. Operation name in the execution's history, up to 237 printable ASCII characters. Keep it short. | -| `title` | Required. What the approver is asked, up to 200 characters. | -| `description` | Optional context, up to 4000 characters. | -| `details` | Optional JSON value shown with the approval. The whole approval, `details` included, must encode to at most 64 KiB of JSON. | -| `timeout` | Optional duration in the `ctx.wait()` format, from one second to 366 days. Without it, the approval stays open as long as the execution runs. | - -Limits count Unicode characters. A blank `name` or `title` raises `ValueError`, as does a value over its limit, a `name` outside printable ASCII, a NUL character anywhere in the text or `details`, or an approval over 64 KiB. A value that is not a string, or that cannot be encoded as JSON, raises `TypeError`. A timeout out of range raises `TypeError`, as it does for `ctx.wait()`. These checks run before anything is recorded. - -The call returns an immutable `ApprovalDecision`: - -| Field | Approved | Denied | Timed out | -|---|---|---|---| -| `approved` | `True` | `False` | `False` | -| `status` | `"approved"` | `"denied"` | `"expired"` | -| `comment` | The approver's comment, or `""` | The approver's comment, or `""` | `""` | -| `decided_by` | `DurableApprovalDecider` with `id` and `email`, or `None` if the account was deleted before the decision reached the execution | Same as approved | `None` | -| `decided_at` | RFC 3339 timestamp | RFC 3339 timestamp | `None` | - -A denial or a timeout is a value to branch on, not an exception. The execution costs nothing while it waits, and a resumed execution replays the recorded decision without asking again. - -Volcano sets `VOLCANO_PLATFORM_API_URL` on deployed and local durable functions. The function sends the approval there without a credential. Volcano accepts it only from the execution that is waiting. When Volcano does not answer, has not seen the execution or its approval yet, throttles the request, or fails, the function retries for up to 30 seconds. An approval whose timeout passes before Volcano records it returns the `expired` decision. If Volcano refuses the approval, or keeps failing for 30 seconds, `wait_for_approval()` raises the durable runtime's `CallbackSubmitterError`, carrying the SDK error's message. Unless the handler catches it, the execution fails. Calling `wait_for_approval()` without `VOLCANO_PLATFORM_API_URL` raises `RuntimeError`. - -## Wait for a message and publish progress - -`ctx.wait_for_message()` pauses the execution until something outside it sends a message with that name: a webhook receiver, a backend, a browser, or another function, through [`client.durable.messages`](#send-and-follow-messages). `ctx.publish()` records a message that applications list or receive live: - -```python -from volcano_sdk.durable_authoring import durable - - -@durable -def handler(event, ctx): - ctx.publish("progress", {"stage": "waiting-for-upload"}) - - upload = ctx.wait_for_message("uploaded", timeout="1d") - if upload is None: - return {"status": "abandoned"} - - report = ctx.step("render", lambda scope: render(upload.data["url"])) - ctx.publish("progress", {"stage": "done", "report_url": report["url"]}) - return {"status": "done", "report_url": report["url"]} -``` - -`wait_for_message(name, timeout=None)` returns a `ReceivedMessage` with `id`, `name`, `data`, `sender` (a `DurableMessageSender` with `type` and `user_id`), and `sent_at` as an RFC 3339 string, or `None` once the timeout passes. Messages sent before anything waits for their name are kept, and each wait takes the oldest one. A message never decides an approval, so use `wait_for_approval()` wherever only a person may answer. - -`publish(name, data=None)` returns a `PublishedMessage` with `id` and `seq`. `data` is any JSON value up to 64 KiB. Publishing is checkpointed, so a replay never publishes twice. The first publish in each context, and in each `ctx.child()`, `ctx.parallel()` branch, or `ctx.map()` item that publishes, opens a messages session, which appears in the execution's history as `volcano:messages-session` and costs three durable operations once. - -Names follow the approval rules: up to 237 printable ASCII characters, checked before anything is recorded, as is the timeout. Both need `VOLCANO_PLATFORM_API_URL`. A wait Volcano does not answer, cannot see yet, throttles, or fails on is retried for the same 30 seconds an approval gets, then raises `CallbackSubmitterError`. A wait Volcano refuses, for example with `too_many_message_waits` when 100 are already open, raises it at once. A refused publish fails its step without a retry, which fails the execution unless the handler catches it. - -## Wait for another system with a callback token - -`ctx.wait_for_callback()` hands a token to another system and pauses the execution until that system answers with it. Use it when the work happens somewhere you do not run code, such as a payment provider, a job queue, or a partner's API, and that system can call back once it is done: - -```python -from volcano_sdk.durable_authoring import CallbackFailedError, durable - - -@durable -def handler(event, ctx): - try: - payment = ctx.wait_for_callback( - "payment", - lambda token: payments.start( - order_id=event["order_id"], callback_token=token - ), - timeout="1h", - heartbeat_timeout="10m", - ) - except CallbackFailedError as error: - return {"status": "declined", "reason": error.type, "message": error.message} - - if payment is None: - return {"status": "abandoned"} - return ctx.step( - "fulfil", lambda scope: fulfil(event["order_id"], payment["charge_id"]) - ) -``` - -The submitter receives a `vcb_` token. The system holding it answers with the token alone and needs no Volcano credential: - -```bash -curl -X POST "https://api.volcano.dev/durable-callbacks/succeed" \ - -H 'Content-Type: application/json' \ - -d '{"token":"vcb_...","result":{"charge_id":"ch_1"}}' - -curl -X POST "https://api.volcano.dev/durable-callbacks/fail" \ - -H 'Content-Type: application/json' \ - -d '{"token":"vcb_...","error":{"type":"CardDeclined","message":"Insufficient funds"}}' - -curl -X POST "https://api.volcano.dev/durable-callbacks/heartbeat" \ - -H 'Content-Type: application/json' \ - -d '{"token":"vcb_..."}' -``` - -`wait_for_callback(name, submitter, timeout=None, heartbeat_timeout=None)` returns the JSON value the token was succeeded with, or `None` once `timeout` passes, or `heartbeat_timeout` passes without a heartbeat. A result is never `None`, so the two cannot be confused. A failure raises `CallbackFailedError` with the `type` and `message` it was failed with. A token takes one answer: a second gets `409 callback_completed`, and one after the wait timed out or the execution ended gets `410`. It works only for this wait, so treat it as a secret. - -The submitter runs inside a checkpointed step that is not retried, so a replay does not run it again. An attempt interrupted before the step is recorded can run it twice, with the same token both times, so make the hand-off idempotent; if the token was answered or the wait timed out in between, it is not run again. When it raises, `wait_for_callback()` raises the runtime's `CallbackSubmitterError`. It must be synchronous: an `async def` submitter raises `TypeError` before the callback opens, and one that returns an awaitable fails the wait instead of handing off. Getting the token needs `VOLCANO_PLATFORM_API_URL` and is retried for the same 30 seconds an approval gets. See [Durable callbacks](/platform/functions/durable-callbacks) for every request, response, and limit. - -## Decide approvals from a backend - -`client.durable.approvals` lists, reads, and decides a project's approvals: - -```python -import os -from datetime import UTC, datetime, timedelta - -from volcano_sdk import ConflictError, VolcanoClient - -owner_client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), - access_token=os.environ["VOLCANO_PLATFORM_TOKEN"], -) -approvals = owner_client.durable.approvals - -page = approvals.list(project_id, status="pending", function="charge-order") -for approval in page.approvals: - print(approval.id, approval.title, approval.details, approval.expires_at) - -try: - approval = approvals.approve(project_id, approval_id, comment="Address checked") -except ConflictError as error: - print(error.code) # approval_decided, approval_expired, or approval_cancelled - -stats = approvals.stats(project_id, from_=datetime.now(UTC) - timedelta(days=7)) -print(stats.counts.pending, stats.approval_rate, stats.median_seconds_to_decision) -``` - -| Method | Returns | -|---|---| -| `list(project_id, status=..., function=..., execution_id=..., from_=..., to=..., page=..., limit=...)` | `DurableApprovalPage` with `approvals`, `page`, `limit`, `total`, and `has_more`. | -| `get(project_id, approval_id)` | `DurableApproval`. | -| `stats(project_id, function=..., from_=..., to=...)` | `DurableApprovalStats` with counts by status, decision times, and per-function and daily counts. | -| `approve(project_id, approval_id, comment=None)` | The decided `DurableApproval`. | -| `deny(project_id, approval_id, comment=None)` | The decided `DurableApproval`. | - -`status` is one of `pending`, `approved`, `denied`, `expired`, or `cancelled`. An approval is `cancelled` when its execution ends first. `function` takes a durable function's id or name. `from_` and `to` must be timezone-aware datetimes. Stats default to the last 30 days and cover at most 366 days. A comment is up to 2000 characters. - -Read approvals with the project owner's platform user token or a project access token. Only a person decides: `approve()` and `deny()` need a platform user token, and a project access token raises `PermissionDeniedError`. Repeating the same decision returns the approval unchanged. A conflicting decision, or deciding an expired or cancelled approval, raises `ConflictError`. An unknown approval raises `NotFoundError`. `PermissionDeniedError` subclasses `AuthenticationError`, so existing handlers still catch it. - -## Send and follow messages - -`client.durable.messages` sends messages to an execution, lists its messages in both directions, and subscribes to what it publishes. It uses the application route with the client's credential, as `durable.start()` does. Pass the execution's `client_token` from the start response when an anon key, or a signed-in user who did not start the execution, should reach it: - -```python -import os - -from volcano_sdk import ConflictError, VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - service_key=os.environ["VOLCANO_SERVICE_KEY"], -) -execution = client.durable.start("report-builder", {"report_id": "7f3a"}) - -try: - message = client.durable.messages.send( - "report-builder", - execution.id, - "uploaded", - {"url": "https://files.example.com/7f3a.csv"}, - idempotency_key="upload-7f3a", - ) -except ConflictError as error: - print(error.code) # execution_not_running - -page = client.durable.messages.list("report-builder", execution.id, direction="out") -for published in page.messages: - print(published.seq, published.name, published.data) -``` - -| Method | Returns | -|---|---| -| `send(function_name, execution_id, name, data=None, idempotency_key=..., client_token=..., project_id=...)` | The stored `DurableMessage`: `queued`, `delivered`, or `delivering` while its handover to a wait is still being confirmed. | -| `list(function_name, execution_id, direction=..., name=..., after=..., limit=..., client_token=..., project_id=...)` | `DurableMessagePage` with `messages`, oldest first, and `has_more`. | -| `await subscribe(function_name, execution_id, handler, client_token=..., project_id=...)` | A `DurableMessageSubscription` with `await unsubscribe()`. | - -A `DurableMessage` has `id`, `execution_id`, `direction` (`"in"` or `"out"`), `seq`, `name`, `data`, `sender`, `status`, `sent_at`, and `delivered_at`. `seq` orders both directions and can skip numbers; read the next page with `after` set to the last `seq`. A send repeating an `idempotency_key` the same sender has used on the execution returns the first message and sends nothing; another sender's keys never collide with it. The key is up to 255 characters. The key and `client_token` travel as headers, so each must be printable ASCII without leading or trailing whitespace, or the call raises `ValueError`. - -`subscribe()` calls the handler, which may be a coroutine function, with each message the execution publishes, in `seq` order, once each, and one call at a time. The messages published before the call are delivered before it returns. A handler that raises during that first read ends the subscription before anything more is delivered, and `subscribe()` raises its error, even when leaving the channel then fails, which is reported to the event loop's exception handler; later, what a handler raises is reported to the event loop's exception handler and delivery goes on. After a reconnect, a live message that shows an earlier one was lost, or a burst the handler falls too far behind on, the missing ones are read back before live delivery resumes; a read that fails is reported to the event loop's exception handler and retried, backing off to every 30 seconds, while the live messages after the gap wait. `client.realtime.remove_all_channels()` and `client.realtime.disconnect()` end the subscription, and a read still in flight delivers nothing; call `unsubscribe()` before subscribing to that execution again. Ended during its first read, the subscription makes `subscribe()` raise `RuntimeError`. The subscription belongs to the session that opened it: once the client signs out, signs in again, or sets another session, it delivers and reads nothing more, and the next read or delivery leaves the execution's channel and raises `RuntimeError`, reported to the event loop's exception handler, or from `subscribe()` while its first read is running. A token refresh keeps it. When the server ends the subscription, or refuses to restore it after a reconnect, as when the execution is no longer visible to the session, the subscription delivers nothing more and leaves the execution's channel, then reports `RuntimeError` with the server's reason to the event loop's exception handler once; the execution can be subscribed to again. If leaving the channel fails, that error is reported too, and the next `subscribe()` to the execution leaves it first. An end or refusal before `subscribe()` returns makes it raise that `RuntimeError` instead, and nothing is reported. It follows the execution on the client's realtime connection, which needs a signed-in session: a client holding only an anonymous or service key gets `RuntimeError`, even with the `client_token`, and can poll `list()` with `after` instead. One client subscribes to an execution once at a time: - -```python -async def follow(client, execution): - subscription = await client.durable.messages.subscribe( - "report-builder", - execution.id, - lambda message: print(message.name, message.data), - client_token=execution.client_token, - ) - ... - await subscription.unsubscribe() -``` - -Pass `project_id` to use the owner's project route with the session's platform token or a project access token instead; a `read_only` project access token can list but not send. A send to an execution that has ended raises `ConflictError` with `code` set to `execution_not_running`, and one to an execution already holding 100 messages nothing has taken raises it with `too_many_queued_messages`. An execution the caller may not reach raises `NotFoundError`, as an unknown one does. - -## Run durable functions locally - -Deploy and start the same handler through the local durable engine: - -```bash -volcano start -volcano durable deploy --all -volcano durable start charge-order --input '{"order_id":"order-9"}' -``` - -Local waits resolve immediately by default while preserving checkpoint and replay behavior. Set `LOCAL_DURABLE_REAL_TIME=true` before `volcano start` when wait timing must match the deployed function. Local executions persist across `volcano stop` and `volcano start`. - -Approvals work the same locally. Decide one with `volcano durable approvals approve` or `volcano durable approvals deny`, or point the owner client at the local server. Approval timeouts run in real time. - -Messages work the same locally too. Send one with `volcano durable messages send`, or point the client at the local server. Message wait timeouts run in real time. - -Callback tokens work locally as well: send the same requests to the local server. A callback's timeout and heartbeat timeout run in real time. - -Running a decorated handler directly in a Python process still needs the optional test runtime: `python -m pip install 'volcano-sdk-python[durable]'`. diff --git a/docs/locks.md b/docs/locks.md deleted file mode 100644 index 592a4faf..00000000 --- a/docs/locks.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Distributed locks -description: Coordinate Python workers with project-scoped lock leases, renewal, and fencing tokens. -order: 7 ---- - -Acquire a project-scoped lease from trusted server code using a service key. -Set `VOLCANO_ANON_KEY` and `VOLCANO_SERVICE_KEY` for the same project. - -```python -import os - -from volcano_sdk import VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - service_key=os.environ["VOLCANO_SERVICE_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), -) -lease = client.locks.acquire("daily-report", ttl=30) -try: - print("Lease acquired", lease.fencing_token) -finally: - client.locks.release("daily-report", lease) -``` - -`acquire()` returns an immutable `LockLease` containing the key, ownership token, expiration, and fencing token. -Release and renew using that lease; the ownership token identifies the holder. -TTLs are integer seconds from 5 seconds through 90 days. -A competing owner can cause `ConflictError`. - -## Renew or inspect a lease - -```python -state = client.locks.get("daily-report") -print(state.held, state.expires_at, state.fencing_token) - -lease = client.locks.acquire("daily-report", ttl=30) -try: - lease = client.locks.renew("daily-report", lease, ttl=60) -finally: - client.locks.release("daily-report", lease) -``` - -`get()` reports availability without acquiring the lock. -`renew()` returns a new immutable lease, so retain its return value. - -## Renew for the duration of a block - -```python -with client.locks.with_lock("daily-report", ttl=30) as guard: - print("Current fence", guard.lease.fencing_token) - if guard.lost: - raise RuntimeError("Lock ownership was lost") -``` - -The context manager renews in the background and attempts to release its latest lease on exit. -`guard.lease` reads the latest lease; `guard.lost` checks ownership loss, and `guard.wait_lost(timeout=1.0)` waits up to one second for it. -Stop protected work when ownership is lost. -Use fencing tokens in the protected data store so an expired worker cannot overwrite work performed by a newer holder. -Lease renewal alone cannot stop application code that is already running. - -If the block succeeds, a renewal failure is raised after cleanup. -An exception from the block takes precedence. - -## Recover an abandoned lock - -`client.locks.force_release("daily-report")` removes the current lease without its ownership token. -Use this only for administrative recovery with fencing enforced by the protected resource. - -## Recover an uncertain acquisition - -Acquisition retries a transport failure or HTTP 503 once with the same ownership -token, request ID, key, TTL, and credential. Other HTTP errors are not retried. -For recovery after that retry also fails, generate and retain identifiers before -acquiring, then reuse them for the same acquisition attempt: - -```python -from uuid import uuid4 - -owner_token = str(uuid4()) -request_id = str(uuid4()) -lease = client.locks.acquire( - "daily-report", ttl=30, token=owner_token, request_id=request_id -) -``` - -Caller-supplied UUID strings retain their exact spelling across retries and lease operations. -Every lock method accepts `request_id`. `with_lock()` also accepts `token` and -`request_id` for acquisition; its renewal and release calls use new request IDs. - -Use a new ownership token for a new lease after release or expiry. Keep ownership -tokens private. A failed response does not prove the server failed to acquire; -reuse the original token to recover the outcome rather than starting a new owner. diff --git a/docs/logs.md b/docs/logs.md deleted file mode 100644 index 839997a2..00000000 --- a/docs/logs.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: Logs -description: Search retained project logs, paginate events, and read activity counts. -order: 6 ---- - -Search logs and inspect activity for resources in your project. - -Project logs require a platform user token or a [project access token](/platform/api-reference/using-the-api). For server-side log readers, create a `read_only` project token and set `VOLCANO_PROJECT_ACCESS_TOKEN`, `VOLCANO_PROJECT_ID`, and `VOLCANO_ANON_KEY`. Keep this credential on your server. The constructor requires an anon key, but the supplied project access token authorizes these requests. Project end-user sign-in, anon keys, and service keys do not grant project log access. - -```python -import os -from volcano_sdk import VolcanoClient - -project_id = os.environ["VOLCANO_PROJECT_ID"] -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), - access_token=os.environ["VOLCANO_PROJECT_ACCESS_TOKEN"], -) -request = {"resource": {"type": "function"}, "q": "checkout_failed", "limit": 100} -page = client.logs.search(project_id, request) -for event in page.data: - print(event["timestamp"], event["body"]) -``` - -`search()` returns an immutable `LogSearchResponse` with `data`, `limit`, `has_more`, and `next_cursor`. - -Both log methods reject malformed response envelopes and row collections with -`TypeError`. The response must be an object whose `data` field is a list of -objects; an empty object is not an empty page. Page limits and activity totals -must be integers, and `has_more` must be a boolean. - -## Continue a search - -```python -if page.has_more and page.next_cursor: - next_page = client.logs.search(project_id, {**request, "cursor": page.next_cursor}) -``` - -## Read activity buckets - -```python -activity = client.logs.activity( - project_id, - {"resource": request["resource"], "q": request["q"], "bucket_count": 24}, -) -print(activity.total) -for bucket in activity.data: - print(bucket["start_time"], bucket["counts"], bucket["total"]) -``` - -Keep the same resource selector, query, and time bounds on every pagination request. Results are newest first. `body` preserves JSON values, including objects and arrays; it is not always a string. Events include a stable `id`, `timestamp`, and owning `resource`. - -Activity returns time buckets and a total count. Each bucket contains `start_time`, `end_time`, `total`, and `counts` grouped by `levels`, `regions`, and `resource_ids`. - -Both methods accept `resource.ids` to restrict results to specific resources, `q` for text or field queries, and RFC3339 `start_time` and `end_time` bounds. Logs arrive asynchronously; use bounded polling when waiting for a new event. These methods read retained logs rather than open a live stream. - -A project token has no refresh token. The SDK sends it as supplied and surfaces authentication failures; rotate or replace expired credentials through project token management. Do not use auth sign-out to revoke a project token. diff --git a/docs/realtime.md b/docs/realtime.md deleted file mode 100644 index 754cccb8..00000000 --- a/docs/realtime.md +++ /dev/null @@ -1,125 +0,0 @@ ---- -title: Realtime -description: Send broadcasts, observe presence, and receive database changes with Python async channels. -order: 8 ---- - -Subscribe to a broadcast channel and send a JSON publication using Python's async lifecycle. -Sign in with the [quickstart credentials](./README.md) and enable the project's realtime capabilities and access policies. - -```python -import asyncio -import os - -from volcano_sdk import VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), -) -client.auth.sign_in( - email=os.environ["VOLCANO_USER_EMAIL"], - password=os.environ["VOLCANO_USER_PASSWORD"], -) - - -async def main(): - channel = client.realtime.channel("updates") - channel.on("message", lambda message: print(message)) - try: - await channel.subscribe() - await channel.send({"event": "message", "value": "hello"}) - await asyncio.sleep(2) - finally: - await client.realtime.disconnect() - - -asyncio.run(main()) -``` - -`subscribe()` waits for the server acknowledgement; a presence subscription also waits for its initial roster. -Channel names receive their type prefix, so `updates` becomes `broadcast:updates`. -Use the same event loop for a client's realtime operations. -The following channel examples belong inside an async function with an authenticated `client`. - -## Pause or remove channels - -```python -channel = client.realtime.channel("updates") -channel.on("message", print) -await channel.subscribe() -await channel.unsubscribe() -await channel.subscribe() -await client.realtime.remove_channel("updates") -``` - -Unsubscribe pauses delivery while retaining handlers and the in-memory broadcast recovery position. -Subscribe resumes and requests missed broadcasts when retained history is available. -Pausing discards queued callbacks; a callback already running may finish. -Removal forgets the channel and its recovery position. -`remove_all_channels()` removes all channels while leaving the shared connection available; `disconnect()` closes it. -Application code owns any work its callbacks start and should await that work during shutdown. - -## Observe presence - -```python -lobby = client.realtime.channel("lobby", channel_type="presence") -stop_sync = lobby.on_presence_sync(lambda state: print("Online", len(state))) -lobby.on("join", lambda info: print("Joined", info.user)) -lobby.on("leave", lambda info: print("Left", info.user)) -await lobby.subscribe() -await lobby.track({"status": "online"}) -print(lobby.tracked_state) -print(lobby.get_presence_state()) -await client.realtime.remove_channel("lobby", channel_type="presence") -stop_sync() -``` - -Presence identity and metadata come from the authenticated user. -`track()` stores optional local application state; it does not replace server-managed presence metadata. -The roster maps connection IDs to immutable client identity and user metadata snapshots. -One user can have several connections. The original sync callback observes connections joining and leaving. -The roster is refreshed after reconnection; a failed roster query reports an error and clears the snapshot. - -## Subscribe to database changes - -Use an existing `app` database and `public.messages` table with realtime and suitable Row-Level Security policies configured: - -```python -client.realtime.set_database_name("app") -changes = client.realtime.channel("public:messages", channel_type="postgres") -stop_changes = changes.on_postgres_changes( - "INSERT", - schema="public", - table="messages", - callback=lambda change: print(change.record), -) -await changes.subscribe() -await asyncio.sleep(30) -stop_changes() -await client.realtime.remove_channel("public:messages", channel_type="postgres") -``` - -Insert a row from another client during the listening period. -Automatic row lookup requires a primary key named `id`. It reads the current row after the notification; rapid updates may have already changed that row. -Matching insert and update notifications can fetch full rows using the subscription's user token. -Compatible row lookups are batched while publication order is preserved. -Defaults are a 20 millisecond window and 50 rows; set `fetch_batch_window_ms` and `fetch_max_batch_size` on the channel to change them. -Set `auto_fetch=False` when first creating the channel or `set_database_name(None)` to retain lightweight notifications without row lookups. -For a custom schema, use its `schema:table` channel name and matching schema/table filters; row lookups preserve that schema. -Missing rows and failed lookups retain the lightweight notification. -Deletes use `old_record` or the row ID and do not query the database. - -## Handle connection changes - -```python -stop_errors = client.realtime.on_error(lambda context: print(context.message)) -stop_connected = client.realtime.on_connect(lambda context: print(context.client)) -stop_disconnected = client.realtime.on_disconnect(lambda context: print(context.reason)) -``` - -Each registration returns an idempotent unsubscribe function. -Callbacks receive immutable contexts and run outside connection processing. -Broadcast recovery stays within one client lifetime and one authenticated session lineage; it is not persisted across processes. -After signing in again or changing users, disconnect before subscribing for the new session. -Do not use realtime delivery as a durable record of every database change. diff --git a/docs/sandboxes.md b/docs/sandboxes.md deleted file mode 100644 index 0877a3a9..00000000 --- a/docs/sandboxes.md +++ /dev/null @@ -1,169 +0,0 @@ ---- -title: Sandboxes -description: Run isolated commands and manage sessions, files, and HTTP services from Python. ---- - -Run a command from trusted backend code in an environment with Sandbox access enabled: - -```python -import os -from uuid import uuid4 -from volcano_sdk import VolcanoClient - -client = VolcanoClient( - anon_key=os.environ["VOLCANO_ANON_KEY"], - service_key=os.environ["VOLCANO_SERVICE_KEY"], - api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), -) -project_id = os.environ["VOLCANO_PROJECT_ID"] -request_id = str(uuid4()) -result = client.sandboxes.exec( - project_id, - 'python -c "print(42)"', - region="us-east-1", - preset="python3.12", - request_id=request_id, -) -print(result.stdout, result.exit_code) -``` - -Keep the same `request_id` when retrying an uncertain create or execution. A new -ID represents a new operation. The SDK does not replay commands after transport failures. A rejected user token -is refreshed once; the retry preserves the request ID. -Nonzero command exits are result fields. A session command reports its timeout -through `timed_out`. One-shot `client.sandboxes.exec()` instead raises -`ServerError(status=504)` when execution times out; retrying the same `request_id` -then raises `ConflictError(status=409)` because the outcome is unknown. Do not -allocate a new request ID to blindly replay an execution with an unknown outcome. -API failures raise typed exceptions such as `ConflictError` or `RateLimitedError`; -these preserve `status`, `code`, and `retry_after` when supplied by the server. - -## Keep a session - -```python -import time - -with client.sandboxes.create( - project_id, - region="us-east-1", - preset="python3.12", - max_duration_seconds=300, -) as session: - for _ in range(60): - if session.refresh().state == "running": - break - time.sleep(1) - else: - raise TimeoutError("Sandbox did not become ready") - session.files.write("/workspace/input.bin", bytes(range(256))) - assert session.files.read("/workspace/input.bin") == bytes(range(256)) - result = session.exec("wc -c /workspace/input.bin") - print(result.stdout) -``` - -Creation, suspension, resumption, and termination are asynchronous. Use `refresh()` -to observe state. Context exit requests termination even when the body raises; -it does not wait for termination to complete. Use `get(session_id)` to reconnect, -then `suspend()`, `resume()`, or `terminate()` as needed. File reads return `bytes`; -writes accept at most 8 MiB. - -Context exit skips an already terminating or terminated session and treats a -cleanup 404 as already removed. Other cleanup failures preserve an existing body -exception and attach a note. Session identity, lifecycle state, expiry, and file -access are read-only properties. `state` is a `SandboxState` literal value; -`session.expires_at` and `access.expires_at` are timezone-aware `datetime` values. - -## Access a background HTTP service - -Inside a running session, detach the service and redirect its streams: - -```python -session.exec( - "nohup python -m http.server 8080 --bind 0.0.0.0 >/tmp/http.log 2>&1 None: pass +# The public quickstart, as published in Kong/volcano-docs +# content/sdk/python/index.md ("Sign in and read a profile"). Change both together. +QUICKSTART = """\ +import os + +from volcano_sdk import VolcanoClient + +client = VolcanoClient( + anon_key=os.environ["VOLCANO_ANON_KEY"], + api_url=os.environ.get("VOLCANO_API_URL", "https://api.volcano.dev"), +) +session = client.auth.sign_in( + email=os.environ["VOLCANO_USER_EMAIL"], + password=os.environ["VOLCANO_USER_PASSWORD"], +) +try: + user = client.auth.get_user() + assert user.id == session.user_id + print(f"Signed in as {user.email}") +finally: + client.auth.sign_out() +""" + + def run_quickstart() -> None: - document = (Path(__file__).resolve().parents[2] / "docs/README.md").read_text() - section = document.split("## Sign in and read a profile\n")[1].split("\n## ")[0] - examples = re.findall(r"```python\n([\s\S]*?)\n```", section) - assert len(examples) == 1, "Expected one complete documented quickstart" user: dict[str, object] = { "id": "22222222-2222-4222-8222-222222222222", "project_id": "11111111-1111-4111-8111-111111111111", @@ -118,9 +137,7 @@ def run_quickstart() -> None: output = io.StringIO() with TemporaryDirectory(prefix="volcano-quickstart-") as directory: example = Path(directory) / "quickstart.py" - example_source = cast("object", examples[0]) - assert isinstance(example_source, str) - _ = example.write_text(example_source) + _ = example.write_text(QUICKSTART) with redirect_stdout(output): _ = runpy.run_path(str(example), run_name="__main__") assert output.getvalue().strip() == f"Signed in as {user['email']}"