S
supero.docs
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 supero
A 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.
FunctionWhen to use itSignature
register_domain()First-time setup — creates a new domain and its admin userregister_domain(domain_name, admin_email, admin_password, domain_display_name=None)
login()A human user authenticating with email + passwordlogin(domain_name, email, password, project="default-project", tenant="default-tenant")
connect()A service or backend job authenticating with an API keyconnect(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"])
MethodReturnsNotes
create(type, parent=None, **fields)dict'name' is required; parent must be a dict with uuid
get(type, uuid)dict | NoneReturns None on 404 instead of raising
get_by_name(type, name)dict | NoneFirst match by name
get_by_fq_name(type, fq_name)dict | Nonefq_name is a list, e.g. [domain, name]
list(type, limit=100, offset=0)listNo filters — use query() for filtered reads
update(type, uuid, **fields)dictPartial update
delete(type, uuid)boolTrue on success, False on failure
exists(type, **filters)boolTrue 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:
MethodReturnsExample
count(type, **filters)intorg.crud.count("task", done=False)
sum(type, field, **filters)numberorg.crud.sum("order", "total", status="completed")
avg(type, field, **filters)floatorg.crud.avg("product", "price")
min(type, field, **filters)valueorg.crud.min("product", "price")
max(type, field, **filters)valueorg.crud.max("order", "total")
distinct(type, field, **filters)listorg.crud.distinct("order", "status")
count_by(type, field, **filters)dictorg.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 again
Pass 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")
ExceptionRaised when
AuthenticationErrorLogin fails, or a request is made with an invalid or expired token
AuthorizationErrorThe authenticated identity lacks permission for the request (HTTP 403)
RateLimitErrorThe 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