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:
- The contract is normative. Where this SDK and the contract differ, this SDK is wrong.
- Every rule cites the backend source it derives from. No rule rests on memory.
- Every rule exists because getting it wrong fails silently. Wrong data, not an error.
- 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, nevermeta.totalCount. Reading the wrong name returns nothing and reports zero, silently, forever. One SDK shipped that for months.- Three response envelopes.
/queryis bare,/auth/*nests under.data,/fileserrors are a bare string. Assuming one shape mis-parses the other two. limitis 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)
| File | Size | Uploaded | |
|---|---|---|---|
| praxsuite-1.1.0.tar.gz | 62.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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}
|