Skip to main content

Praxsuite SDK for Python

Auth, queries and server-authoritative endpoints for your Praxsuite workspace.

Zero dependencies. Everything here is standard library, so pip install praxsuite adds nothing to your lock file — which is the difference between "usable" and "argued about" in a locked service image, a Lambda bundle, or a notebook someone else has to reproduce.

from praxsuite import Praxsuite, filters as f

prax = Praxsuite("your-workspace-id", "sk_live_...")

page = (prax.data.table("Orders")
        .select("ID", "Total", "Status")
        .where(f.gte("Total", 100), Status="paid")
        .order_by("Total", ascending=False)
        .limit(50)
        .fetch())

for row in page:
    print(row["ID"], row["Total"])

Install

pip install praxsuite

Python 3.9+. Fully typed, ships py.typed.

Configure

prax = Praxsuite("your-workspace-id", "sk_live_...")

Or from the environment, which is what you want in a deployed service:

export PRAXSUITE_WORKSPACE_ID=...
export PRAXSUITE_API_KEY=sk_live_...
export PRAXSUITE_BASE_URL=https://gateway.praxsuite.com   # optional
prax = Praxsuite()

Both values come from your workspace under API Gateway. The client is cheap to construct and safe to share between threads; one instance per workspace per process is the usual arrangement.

Which key to use

Unlike the game-engine SDKs, a secret key is usually correct here — a Python process is normally a server you control, and that is what sk_live_ is for.

The exception is code a user can read: a published notebook, a Pyodide or PyScript page, a desktop app, a shared Jupyter kernel. Pass client_side=True and the SDK refuses a secret key outright rather than trusting you to remember:

prax = Praxsuite(workspace_id, "pk_live_...", client_side=True)

Two things to know regardless:

Every credential carries both halves. There is no publishable-only credential, and /{workspace}/auth/config is unauthenticated — so the workspace id alone yields the publishable key, and whatever tables you scope to that credential are reachable by anyone who has it. Scope narrowly and keep the rest on a credential your client never sees.

Anything a caller must not influence belongs in an endpoint. The client asks for an outcome; the server decides it.

result = prax.endpoints.call("submit-score", {"score": score})

Querying

page = (prax.data.table("Scores")
        .select("Player", "Score")
        .where(f.gte("Score", 100))
        .where(Season=3)                    # kwargs are shorthand for equality
        .order_by("Score", ascending=False)
        .limit(20)
        .fetch())

print(len(page), "of", page.total)          # total is None unless you asked for it

Nothing is sent until a terminal method — fetch(), first(), count(), exists(), all(). A Page is iterable and indexable, so for row in page and page[0] both work.

To page through everything:

for row in prax.data.table("Scores").where(Season=3).all(page_size=200):
    ...

all() reads back the limit the server actually applied rather than assuming yours was honoured — a table scope can clamp it, and assuming otherwise turns pagination into an infinite loop.

filters exposes only operators the gateway implements: eq neq gt gte lt lte like ilike in is between contains textsearch. The friendly-sounding ones compile down — starts_with becomes like "value%", is_null becomes is null. Offering startsWith as an operator would only produce a 400 at runtime.

page.total is None when a count was not requested, not 0, so "no rows matched" stays distinguishable from "nobody asked". Call .with_total_count() or .count() for a real number.

Writes

prax.data.insert("Saves", {"Slot": 1, "Level": 12})
prax.data.update_by_id("Saves", row_id, {"Level": 13})
prax.data.update("Saves", {"Level": 13}, f.eq("Slot", 1))
prax.data.delete("Saves", f.eq("Slot", 1))

update() and delete() take their conditions positionally and require at least one, so an unscoped write cannot be written by accident. Do not set an ownership column yourself — see below.

Errors

Exceptions, since that is what Python code expects:

from praxsuite import PraxRateLimitError, PraxQuotaExceededError, PraxError

try:
    page = prax.data.table("Scores").fetch()
except PraxRateLimitError:
    ...          # transient — back off and retry
except PraxQuotaExceededError:
    ...          # NOT transient — the workspace owner has to upgrade
except PraxError as exc:
    print(exc.code, exc.status, exc.details)

Both of those arrive as HTTP 429 and mean opposite things, which is exactly why they are separate types. PraxValidationError is also a ValueError, so code already catching ValueError for bad arguments keeps working.

Reads are retried automatically with backoff on transient failures. Writes and endpoint calls are not: retrying a failed insert is how you get two rows.

Async

For FastAPI and anything else on an event loop:

from praxsuite.aio import AsyncPraxsuite

prax = AsyncPraxsuite("workspace-id", "sk_live_...")
page = await prax.query("Orders").where(Status="paid").limit(50).fetch()

Being precise about what this is, because a wrapper that hid it would be misleading: every call runs the blocking request on a worker thread via asyncio.to_thread. It is not native async I/O. What that buys you is the thing that matters in a web service — your event loop is never blocked, so one slow gateway call does not stall every other request. What it does not buy you is a native async client's memory profile at very high concurrency, since each in-flight call holds a thread.

That trade exists to keep the dependency count at zero. If your workload is thousands of concurrent gateway calls rather than dozens, use the sync client from a thread pool you size yourself.

Builder methods stay synchronous (building sends nothing); only terminals are awaited.

Logging

Standard logging, under the praxsuite logger, with credentials scrubbed from every record — including ones passed as lazy %s arguments.

import logging
logging.getLogger("praxsuite").setLevel(logging.DEBUG)   # logs request/response bodies

Leave that at default in production.


Per-player isolation needs TWO settings

This is the most damaging misconfiguration in the platform, so it is worth stating plainly.

Setting Where Value Covers
Row filter the role's table scope __SELF__ select, update, delete
Default value template the ownership column's scope {{claim:sub}} insert

The row filter cannot cover inserts, because an insert has no WHERE clause to constrain. Configure only the row filter and inserts succeed with a null owner, which the filter then hides — the user saves a record and cannot read it back, with no error raised anywhere.

The default value template also blocks the client from setting the column at all, which is what makes ownership untamperable. That rejection is the guarantee; do not work around it.


Conformance is the law

Praxsuite has SDKs in several languages. Where they touch the gateway they do not get to disagree. A single normative contract — the internal Praxsuite-SDK-Conformance repository — defines the shared behaviour, and every SDK implements it identically:

  1. The contract is normative. Where this SDK and the contract differ, this SDK is wrong.
  2. Every rule cites the backend source it derives from. No rule rests on memory.
  3. Every rule exists because getting it wrong fails silently. Wrong data, not an error.
  4. A behaviour change is a contract change first. Not an implementation detail.

The contract is internal and deliberately has no public repository. Its value is that it is authoritative for us, not that it is browsable — and it cites backend internals that are not ours to publish. Everything a consumer of this SDK needs is in this README.

What it pins down, and why each one earned its place:

  • Operators. Only the thirteen the parser accepts. A friendlier name is a runtime 400.
  • meta.total, never meta.totalCount. Reading the wrong name returns nothing and reports zero, silently, forever. One SDK shipped that for months.
  • Three response envelopes. /query is bare, /auth/* nests under .data, /files errors are a bare string. Assuming one shape mis-parses the other two.
  • limit is clamped up to a minimum of 1. A zero-row count request quietly returns a row.
  • Unscoped updates and deletes refused before sending, synchronously.
  • No client-supplied identity parameter. The server ignores it, so it would read as a security boundary while being decorative.

The suite is offline — no workspace, no network, no credentials:

pip install -e ".[dev]"
pytest

The Event Bus

Ephemeral realtime between connected clients: live cursors, avatars, "user is typing", a multiplayer lobby. State that is changing, where losing a message is fine because a newer one is 100ms behind it.

prax.auth.login(email, password)      # the bus needs a signed-in user, not the workspace key

room = prax.bus.topic("office").channel("hq")     # the bus "office:hq"

room.on("move", lambda e: move_avatar(e.from_user_id, e.payload))
room.on_peer_left(remove_avatar)

# join() returns everyone already there, so a late arrival sees the room rather than an
# empty one until somebody happens to move.
for peer in room.join():
    move_avatar(peer.user_id, peer.payload)

room.publish("move", {"x": x, "y": y})

A topic must exist before anyone can join it. Declare it once in the portal under API Gateway / Event Bus (or with the create_bus_topic MCP tool) and pick its access rule: open to any signed-in user, gated on a role from their token, or gated on a grant on that one bus instance. An undeclared topic is refused - which is what stops somebody else's client squatting in your namespace.

prax.bus.self_channel is the caller's own bus, user:self. The server resolves it to their id, so it can never address anybody else.

Three things about it are not obvious and will bite:

  • Nothing is persisted. No history, no retry, no delivery to somebody who was not connected. The test is one question: if this is lost, does it matter? Yes means it belongs in a table via prax.data, or in an automation. No, because a newer one is coming, means it belongs here. A chat feature uses both: the automation stores the message, the bus makes it appear instantly.
  • Payloads are hostile. The bus relays opaque JSON between users and parses none of it, so every server-side sanitizer is bypassed. Rendering one as HTML is a stored XSS delivered peer to peer. Treat it the way you would treat a URL query string.
  • You never receive your own event. Apply your own change locally.

publish() does not raise when the bus refuses a frame - dropping an ephemeral message is ordinary operation. Read the result when you care:

r = room.publish("move", {"x": x, "y": y})
if not r.ok:
    print(r.error)          # e.g. 'rate_limited'
if r.recipients == 0:
    pass                    # it went out, and nobody was joined

join() is the opposite and raises: a publish that does not land is one lost frame, a join that does not land leaves this client silently absent for the whole session.

Inbound messages arrive on a background thread, so a handler that blocks delays every other handler. Hand slow work to a queue.

Transport. SignalR's long-polling transport, not WebSockets: the standard library has no WebSocket client and this SDK has zero dependencies, so the alternative was dragging in websockets or shipping no bus at all. Long polling is not slow polling - the GET is held open and returns the moment a message is ready. What it costs is one HTTP round trip per publish.

Signing in with an external provider

for provider in prax.auth.providers():
    print(provider.slug, provider.display_name)

start = prax.auth.start_oidc_login("tesseract")
webbrowser.open(start.authorization_url)      # keep start.state

# ...once the provider has redirected back with code and state:
prax.auth.complete_oidc_login(
    "tesseract",
    code,
    state,
    "https://app.example/callback",   # byte-identical to the provider's configured redirect URI
)

All four arguments are required, and three of them are why an external sign-in fails when it fails: the gateway scopes its one-time state per provider, consumes it once, and compares redirect_uri against the value configured for that provider. Pass the URI you were actually redirected to rather than rebuilding it - that is how it ends up differing by a trailing slash and failing with a message about redirect URIs that nobody can act on.

The session lands in the same place a password login puts it, so refresh, sign-out and every authenticated call behave identically afterwards.

Only the authorization-code flow exists. There is no route that accepts a provider's own id_token, so even a native button has to make the browser hop.

API surface

Praxsuite(...) workspace_id, credential, base_url, client_side, timeout, max_attempts
prax.auth register login logout refresh ensure_fresh_session forgot_password verify_reset_code reset_password resend_confirmation get_config on_session_change providers start_oidc_login complete_oidc_login
prax.data table(name) → query builder; insert insert_many update update_by_id delete delete_by_id upsert execute
prax.endpoints call get
prax.schema tables table columns has_table
prax.bus topic(key) -> .channel(instance); channel(bus_key) self_channel connect disconnect; a channel has join publish leave peers on on_any on_peer_joined on_peer_left on_evicted
filters eq neq gt gte lt lte like ilike contains text_search starts_with ends_with is_null is_not_null in_ between any_of all_of

Licence

Praxsuite Open SDK Licence — source-available, not OSI open source.

Free to use in anything you build, including products you sell. Free to fork, modify and publish your changes. Not free to resell as an SDK, or to point at a competing backend.

Derived from the Praxsuite SDK — https://praxsuite.com

Metadata

Release files for praxsuite 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for praxsuite 1.1.0
File Size Uploaded
praxsuite-1.1.0.tar.gz 62.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for praxsuite 1.1.0
File Interpreter ABI Platform
praxsuite-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 120.7 kB

Release files / praxsuite-1.1.0.tar.gz

Download URL praxsuite-1.1.0.tar.gz
Size 62.6 kB
Tags Source
SHA-256 checksum
How to use checksums
9904f9bc79950773f93aff181781eab0319c70d301081ce0f854ef08735644ed
BLAKE2b-256 checksum
How to use checksums
ba9af51c904867490d667df1c90e791566385e70f44033ffd53e5b501b3f646f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / praxsuite-1.1.0-py3-none-any.whl

Download URL praxsuite-1.1.0-py3-none-any.whl
Size 58.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1a9ce48dc000e9895314f13b85d663ccb491b1907595bcf07c390bb4228ab183
BLAKE2b-256 checksum
How to use checksums
9f9bec85e59d7dbb66f4ade7c32104ed67a3b1a1548d6f181319c29610f67210
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page