Developers/SDK/Python SDK
Python SDK
The official Python client for Supero — a synchronous library that wraps the REST API with a connected "org" object, a fluent query builder, schema upload, and file handling.
Overview & Installation
ℹ️ Three different Python minimums, and they are all correct
You will see three versions quoted across these docs, and it is not a contradiction — three different things have three different requirements. The SDK library itself installs on Python 3.8 and above. A generated application's run.sh requires 3.9 or newer, because the app runner uses features the library does not. The on-premise add-on packages require exactly 3.10: each ships a self-contained virtual environment built against that version and pins it, so a different host Python fails with an ABI mismatch rather than a version warning. Match the requirement to the thing you are actually installing.
The Supero Python SDK gives you a connected client ("org") that carries your domain, project, tenant, and auth context so every call after login or connect is scoped automatically. It is fully synchronous — there is no async client and no separate low-level "typed" client to choose between; org.crud works immediately against any uploaded schema.
bash
pip install superoA minimal end-to-end example: connect with an API key and create a record.
python
from supero import connect
org = connect("acme-corp", api_key="ak_...", project="ecommerce")
task = org.crud.create("task", name="task-001", title="Learn Supero", priority="high")
print(task["uuid"])ℹ️ Records are plain dicts
org.crud.* methods return and accept plain Python dicts, not model objects. Every record carries uuid, created_at, updated_at, fq_name, parent_type, and parent_uuid — key on uuid, never id.
Connecting
There are three top-level entry points, imported directly from supero. All of them return a connected org instance.
| Function | When to use it | Signature |
|---|---|---|
| register_domain() | First-time setup — creates a new domain and its admin user | register_domain(domain_name, admin_email, admin_password, domain_display_name=None) |
| login() | A human user authenticating with email + password | login(domain_name, email, password, project="default-project", tenant="default-tenant") |
| connect() | A service or backend job authenticating with an API key | connect(domain_name, api_key="ak_...", project="default-project", tenant="default-tenant") |
⚠️ The tenant default is the project-wide one
Both signatures default tenant to "default-tenant", and membership of a tenant named default-tenant grants project-wide access to every tenant in that project regardless of role. Leaving the default in place is correct for a build script or an admin tool that is meant to reach the whole project. It is wrong for anything your application runs with: pass tenant="<your-named-tenant>" explicitly so the session is confined to one customer's data. See Building Multi-Tenant Apps.
python
from supero import register_domain, login, connect
# First time: create the domain
org = register_domain(
domain_name="acme-corp",
admin_email="[email protected]",
admin_password="SecurePass123!",
)
# Returning user: log in with credentials, scoped to a project
org = login("acme-corp", "[email protected]", "password", project="ecommerce")
# Multi-tenant apps: scope to a tenant too
org = login("acme-corp", "[email protected]", "password", project="hr-system", tenant="sales")
# Service-to-service: connect with an API key instead of a password
org = connect("acme-corp", api_key="ak_...", project="ecommerce")login() returns an org whose jwt_token property holds the access token; project_uuid, tenant_uuid, domain_name, and user_email are also available on the returned object. Switch project or tenant context later without a fresh login using org.switch_project(project_name=..., tenant_name=...), which re-issues the token.
⚠️ Two auth schemes, never mixed
login()/register_domain() authenticate a user and carry a Bearer JWT internally. connect() authenticates a service with an API key sent as X-API-Key. Never pass an API key where a JWT is expected or vice versa — pick the entry point that matches how the caller authenticates.
Schemas & Namespaces
Schemas are JSON files uploaded to your domain/project before you can create records of that type. Each schema declares its fields, an optional parent_type (what a record's parent object is), and an optional namespace for grouping.
json
{
"name": "task",
"namespace": "productivity",
"schema_type": "object",
"schema_content": {
"name": "task",
"parent_type": "tenant",
"attributes": [
{"name": "title", "type": "string", "mandatory": true},
{"name": "priority", "type": "string", "values": ["low", "medium", "high"], "default-value": "medium"},
{"name": "done", "type": "boolean", "default-value": false}
]
}
}python
# Upload every schema in a directory (recurses by default)
org.upload_schemas("schemas/")
# Upload one file
org.upload_schema("schemas/objects/task.json")Namespace-qualified types
A namespace groups related schemas and disambiguates same-named types. Reference a namespaced schema as "namespace:type" anywhere a type name is expected, or use org.crud.ns(namespace) for field-style access without repeating the prefix:
python
# Equivalent ways to address a namespaced type
order = org.crud.create("billing:order", name="order-1", total=99.99)
order = org.crud.ns("billing").order.create(name="order-1", total=99.99)
orders = org.crud.list("billing:order")
orders = org.crud.ns("billing").order.list()⚠️ Bare names must be unambiguous
If the same bare type name exists in more than one namespace, an unqualified call is rejected — always qualify with namespace:type (or org.crud.ns(...)) once a name collides.
parent_type determines scoping, not the create() call
A record's scope (tenant-visible vs domain-visible) is derived from its schema's declared parent_type, not from arguments passed to crud.create(). Declare parent_type: "tenant" on schemas whose records should be readable in a tenant-scoped session. For child records nested under another record (e.g. line items under an order), set parent_type to that container schema's bare name and pass parent= on create so fq_name and parent_uuid are filled in automatically.
CRUD Operations
All CRUD lives under org.crud. Every method takes the schema type name first; create() and update() take fields as keyword arguments.
python
# Create — 'name' is required
task = org.crud.create("task", name="task-001", title="Learn Supero", priority="high")
# Create as a child of another record
customer = org.crud.get("customer", customer_uuid)
order = org.crud.create("order", parent=customer, name="order-001", total=199.99)
# Read
task = org.crud.get("task", task["uuid"]) # None if not found
task = org.crud.get_by_name("task", "task-001")
task = org.crud.get_by_fq_name("task", ["acme-corp", "task-001"])
# List (returns a plain list, not a paginated envelope)
tasks = org.crud.list("task")
tasks = org.crud.list("task", limit=25, offset=50)
# Update
task = org.crud.update("task", task["uuid"], done=True, priority="low")
# Delete — returns True/False
ok = org.crud.delete("task", task["uuid"])| Method | Returns | Notes |
|---|---|---|
| create(type, parent=None, **fields) | dict | 'name' is required; parent must be a dict with uuid |
| get(type, uuid) | dict | None | Returns None on 404 instead of raising |
| get_by_name(type, name) | dict | None | First match by name |
| get_by_fq_name(type, fq_name) | dict | None | fq_name is a list, e.g. [domain, name] |
| list(type, limit=100, offset=0) | list | No filters — use query() for filtered reads |
| update(type, uuid, **fields) | dict | Partial update |
| delete(type, uuid) | bool | True on success, False on failure |
| exists(type, **filters) | bool | True if any record matches |
| get_or_create(type, name, **defaults) | (dict, bool) | bool is True if a new record was created |
| update_or_create(type, name, **fields) | (dict, bool) | Creates if no record with that name exists |
References and bulk operations
Link two records with set_ref(), inspect a link with get_ref(), and drop it with remove_ref(). For batches, use the bulk_* methods instead of looping:
python
org.crud.set_ref("order", order["uuid"], "customer", customer["uuid"], role="billing")
ref = org.crud.get_ref("order", order["uuid"], "customer")
org.crud.remove_ref("order", order["uuid"], "customer")
tasks = org.crud.bulk_create("task", [
{"name": "task-1", "title": "First"},
{"name": "task-2", "title": "Second"},
])
org.crud.bulk_update("task", [{"uuid": "u1", "done": True}, {"uuid": "u2", "done": True}])
org.crud.bulk_delete("task", ["u1", "u2"])Query Builder & Aggregates
org.crud.query(type) starts a chainable, filtered query. Filters are combined with AND; order_by() takes field names, prefixed with "-" for descending.
python
tasks = org.crud.query("task") \
.filter(done=False, priority="high") \
.order_by("-created_at") \
.limit(10) \
.all()
first = org.crud.query("task").filter(priority="high").first()
count = org.crud.query("task").filter(done=False).count()
exists = org.crud.query("task").filter(priority="high").exists()
deleted = org.crud.query("task").filter(done=True).delete_all()
# Page-based pagination
page3 = org.crud.query("task").paginate(page=3, per_page=25).all()
# Shorthand for a single-shot filtered read
high_priority = org.crud.find("task", priority="high", done=False)Aggregate helpers are also on org.crud and accept the same **filters keyword pattern:
| Method | Returns | Example |
|---|---|---|
| count(type, **filters) | int | org.crud.count("task", done=False) |
| sum(type, field, **filters) | number | org.crud.sum("order", "total", status="completed") |
| avg(type, field, **filters) | float | org.crud.avg("product", "price") |
| min(type, field, **filters) | value | org.crud.min("product", "price") |
| max(type, field, **filters) | value | org.crud.max("order", "total") |
| distinct(type, field, **filters) | list | org.crud.distinct("order", "status") |
| count_by(type, field, **filters) | dict | org.crud.count_by("task", "priority") |
| stats(type, field, **filters) | dict | {count, sum, avg, min, max} for one field |
Files & API Keys
Files
org.files uploads binary content and, optionally, links the result straight into a record field in one call.
python
# Upload only — returns a FileReference (file_id, url, thumbnail_url, mime_type, ...)
ref = org.files.upload("photos/front-view.jpg")
# Upload and attach to a record's field in one step
ref = org.files.attach(customer["uuid"], "customer", "avatar", "photo.png")
# Gallery / list fields append by default
ref = org.files.attach(project["uuid"], "project", "gallery", "render.jpg")
result = org.files.upload_batch(["a.jpg", "b.jpg", "c.jpg"]) # up to 10 per call
print(result.succeeded, result.failed_count)API keys
Mint a service API key for a domain through the CRUD-style api-key endpoint while logged in as an admin. The plaintext key (prefixed ak_) is only ever returned once — store it immediately.
python
resp = org.post(
f"/api/v1/domains/acme-corp/api-key",
json={"name": "backend-service", "scopes": ["read", "write"]},
)
api_key = resp["api_key"] # store this now — it is not retrievable againPass a caller-specific key to any CRUD or query call with api_key= to run that one call under a different identity's RBAC scope, instead of the org's own session:
python
task = org.crud.create("task", name="t1", title="Test", api_key=user_api_key)
tasks = org.crud.query("task").with_api_key(user_api_key).filter(done=False).all()Error Handling
The SDK raises a small set of real exceptions, all importable from the top-level supero package:
python
from supero import AuthenticationError, AuthorizationError, RateLimitError
try:
org = login("acme-corp", "[email protected]", "wrong-password")
except AuthenticationError:
print("invalid credentials or expired token")
except AuthorizationError:
print("permission denied for this domain/project/tenant")
except RateLimitError as e:
print(f"rate limited, retry after {e.retry_after}s")| Exception | Raised when |
|---|---|
| AuthenticationError | Login fails, or a request is made with an invalid or expired token |
| AuthorizationError | The authenticated identity lacks permission for the request (HTTP 403) |
| RateLimitError | The domain has exceeded its request rate limit (HTTP 429); carries retry_after |
ℹ️ Not-found is not an exception
org.crud.get() returns None and org.crud.delete() returns False on a missing record instead of raising — check the return value rather than wrapping every read in try/except.
Next steps
- •JavaScript SDK — the equivalent client for Node and browser apps
- •Schema API — the full schema definition reference (fields, references, extends)
- •CRUD Endpoints — the underlying REST routes org.crud wraps, for calling directly with curl or another language
- •Authentication — JWT and API-key mechanics, token lifetimes, and refresh/logout
On this page