Skip to main content

Python client SDK for noex-server

Project description

noex-client

Python client SDK for noex-server. Asyncio-native, 1:1 feature parity with the TypeScript client.

Features

  • Store CRUD with bucket API, cursor pagination, and aggregation
  • Reactive subscriptions -- subscribe to server-side queries, receive push updates via callbacks
  • Transactions -- atomic multi-bucket operations
  • Rules engine proxy -- emit events, manage facts, subscribe to rule matches
  • Identity & auth -- built-in user/role management, ACL, token and credential login
  • Audit & procedures -- audit log queries, server-side procedure execution
  • Automatic reconnect with exponential backoff, jitter, and subscription recovery
  • Heartbeat -- automatic pong responses to server ping
  • Type-safe -- full type hints, strict mypy, TypedDict for protocol structures
  • Minimal dependencies -- only websockets (>=13.0)

Installation

pip install noex-client

Requires Python >= 3.11.

Quick Start

import asyncio
from noex_client import NoexClient

async def main():
    client = NoexClient("ws://localhost:8080")
    await client.connect()

    # Store CRUD
    users = client.store.bucket("users")
    alice = await users.insert({"name": "Alice"})
    all_users = await users.all()

    # Reactive subscription
    unsub = await client.store.subscribe("all-users", lambda data: print("Updated:", data))

    # Rules
    await client.rules.emit("user.created", {"userId": alice["id"]})

    # Cleanup
    unsub()
    await client.disconnect()

asyncio.run(main())

Context Manager

async with NoexClient("ws://localhost:8080") as client:
    users = client.store.bucket("users")
    await users.insert({"name": "Alice"})
# Automatically disconnects

Auth and Reconnect

from noex_client import NoexClient, ClientOptions, AuthOptions, ReconnectOptions

client = NoexClient("ws://localhost:8080", ClientOptions(
    auth=AuthOptions(token="my-jwt-token"),
    reconnect=ReconnectOptions(
        max_retries=10,
        initial_delay_ms=500,
        max_delay_ms=15_000,
    ),
    request_timeout_ms=5_000,
))

client.on("reconnecting", lambda attempt: print(f"Reconnecting... attempt {attempt}"))
client.on("reconnected", lambda: print("Reconnected! Subscriptions restored."))

await client.connect()

When auth.token is set and the server requires authentication, the client automatically sends auth.login after connecting and after every reconnect.


API

NoexClient

NoexClient(url, options=None)

Creates a client instance. Does not open a connection -- call connect() to start.

client = NoexClient("ws://localhost:8080", ClientOptions(
    auth=AuthOptions(token="jwt"),
    reconnect=True,
    request_timeout_ms=10_000,
    connect_timeout_ms=5_000,
    heartbeat=True,
))

await client.connect() -> WelcomeInfo

Opens the WebSocket connection and waits for the server welcome message. If auth is configured and the server requires authentication, login is performed automatically.

welcome = await client.connect()
# WelcomeInfo(version='1.0.0', server_time=1706745600000, requires_auth=True)

await client.disconnect() -> None

Gracefully closes the connection. Rejects all pending requests, clears subscriptions, and stops any reconnect loop.

client.state -> ConnectionState

Current connection state: "connecting" | "connected" | "reconnecting" | "disconnected".

client.is_connected -> bool

Shorthand for client.state == "connected".

client.on(event, handler) -> Unsubscribe

Subscribe to client lifecycle events. Returns an unsubscribe function.

Event Handler signature Description
"connected" () -> None Connection established (initial or reconnect)
"disconnected" (reason: str) -> None Connection lost or closed
"reconnecting" (attempt: int) -> None Reconnect attempt starting
"reconnected" () -> None Successfully reconnected
"error" (error: Exception) -> None Transport or reconnect error
"welcome" (info: WelcomeInfo) -> None Welcome message received from server
"session_revoked" () -> None Server revoked the current session

ClientOptions

@dataclass(frozen=True)
class ClientOptions:
    auth: AuthOptions | None = None
    reconnect: bool | ReconnectOptions = True
    request_timeout_ms: int = 10_000
    connect_timeout_ms: int = 5_000
    heartbeat: bool = True
Option Type Default Description
auth AuthOptions None Auth configuration for automatic login
reconnect bool | ReconnectOptions True Enable automatic reconnect with exponential backoff
request_timeout_ms int 10000 Timeout for individual request/response round-trips
connect_timeout_ms int 5000 Timeout for WebSocket connection and welcome message
heartbeat bool True Automatically respond to server ping messages

AuthOptions

@dataclass(frozen=True)
class AuthOptions:
    token: str | None = None                     # Token for auth.login
    credentials: CredentialOptions | None = None  # Username/password for identity.login

ReconnectOptions

@dataclass(frozen=True)
class ReconnectOptions:
    max_retries: float = float("inf")
    initial_delay_ms: int = 1_000
    max_delay_ms: int = 30_000
    backoff_multiplier: float = 2.0
    jitter_ms: int = 500

StoreAPI

Access via client.store.

store.bucket(name) -> BucketAPI

Returns a BucketAPI handle for the named bucket. Does not make a request -- the bucket handle is a thin wrapper that attaches the bucket name to each operation.

users = client.store.bucket("users")

await store.subscribe(query, callback, params=None) -> Unsubscribe

Subscribe to a reactive server-side query. The callback receives the initial data immediately and is called again whenever the query result changes on the server.

unsub = await client.store.subscribe("all-users", lambda users: print("Users:", users))

# With parameters
unsub = await client.store.subscribe(
    "users-by-role",
    lambda admins: print("Admins:", admins),
    params={"role": "admin"},
)

# Unsubscribe (synchronous)
unsub()

Subscriptions survive reconnect -- after a successful reconnect the client automatically resubscribes and delivers fresh data to the callback.

await store.unsubscribe(subscription_id) -> None

Cancel a subscription by its server-assigned ID.

await store.transaction(operations) -> dict

Execute multiple store operations atomically.

result = await client.store.transaction([
    {"op": "get", "bucket": "users", "key": "user-1"},
    {"op": "update", "bucket": "users", "key": "user-1", "data": {"credits": 400}},
    {"op": "insert", "bucket": "logs", "data": {"action": "credit_update"}},
])

Supported ops: get, insert, update, delete, where, findOne, count.

Admin -- Bucket Management

await store.define_bucket("users", {"schema": {"name": {"type": "string"}}})
await store.update_bucket("users", {"schema": {"email": {"type": "string"}}})
schema = await store.get_bucket_schema("users")
await store.drop_bucket("users")

Admin -- Query Management

await store.define_query("all-users", {"type": "all", "bucket": "users"})
queries = await store.list_queries()
await store.undefine_query("all-users")

Metadata

buckets = await store.buckets()
stats = await store.stats()

BucketAPI

Access via client.store.bucket(name).

CRUD

Method Returns
await bucket.insert(data) dict -- inserted record with metadata
await bucket.get(key) dict | None
await bucket.update(key, data) dict -- updated record
await bucket.delete(key) None

Queries

Method Returns
await bucket.all() list[dict]
await bucket.where(filter) list[dict]
await bucket.find_one(filter) dict | None
await bucket.count(filter=None) int
await bucket.first(n) list[dict]
await bucket.last(n) list[dict]
await bucket.paginate(limit=..., after=...) dict -- paginated result

Aggregation

Method Returns
await bucket.sum(field, filter=None) float
await bucket.avg(field, filter=None) float
await bucket.min(field, filter=None) float | None
await bucket.max(field, filter=None) float | None

Bulk

Method Description
await bucket.clear() Remove all records from the bucket

RulesAPI

Access via client.rules.

Events

event = await client.rules.emit("user.created", {"userId": "123"})
# {'id': '...', 'topic': 'user.created', 'data': {...}, 'timestamp': ...}

# With correlation/causation IDs
event = await client.rules.emit(
    "order.completed",
    {"orderId": "456"},
    correlation_id="corr-1",
    causation_id="cause-1",
)

Facts

await client.rules.set_fact("user:1:status", "active")
status = await client.rules.get_fact("user:1:status")
deleted = await client.rules.delete_fact("user:1:status")
facts = await client.rules.query_facts("user:*:status")
all_facts = await client.rules.get_all_facts()

Subscriptions

Subscribe to real-time rule events by topic pattern:

unsub = await client.rules.subscribe("user.*", lambda event, topic: print(f"{topic}: {event}"))

unsub()

Admin

await client.rules.register_rule({"id": "my-rule", "when": {...}, "then": {...}})
await client.rules.enable_rule("my-rule")
await client.rules.disable_rule("my-rule")
rule = await client.rules.get_rule("my-rule")
rules = await client.rules.get_rules()
await client.rules.update_rule("my-rule", {"then": {...}})
validation = await client.rules.validate_rule({...})
await client.rules.unregister_rule("my-rule")

Stats

stats = await client.rules.stats()
# {'rulesCount': ..., 'factsCount': ..., 'eventsProcessed': ...}

AuthAPI

Access via client.auth.

session = await client.auth.login("jwt-token")
# {'userId': '...', 'roles': [...], 'expiresAt': ...}

current = await client.auth.whoami()
await client.auth.logout()

When auth.token is set in ClientOptions, login is performed automatically after connect and after each reconnect.


IdentityAPI

Access via client.identity. Built-in user management with roles and ACL.

Auth

result = await client.identity.login("admin", "password")
result = await client.identity.login_with_secret("admin-secret")
me = await client.identity.whoami()
session = await client.identity.refresh_session()
await client.identity.logout()

When auth.credentials is set in ClientOptions, credential login is performed automatically after connect and after each reconnect.

User Management

user = await client.identity.create_user({"username": "alice", "password": "s3cret"})
user = await client.identity.get_user(user_id)
await client.identity.update_user(user_id, {"displayName": "Alice"})
users = await client.identity.list_users(page=1, page_size=20)
await client.identity.enable_user(user_id)
await client.identity.disable_user(user_id)
await client.identity.delete_user(user_id)

Password

await client.identity.change_password(user_id, "old-pass", "new-pass")
await client.identity.reset_password(user_id, "new-pass")

Roles

role = await client.identity.create_role({"name": "editor", "permissions": [...]})
await client.identity.assign_role(user_id, "editor")
roles = await client.identity.get_user_roles(user_id)
await client.identity.remove_role(user_id, "editor")
all_roles = await client.identity.list_roles()
await client.identity.update_role(role_id, {"permissions": [...]})
await client.identity.delete_role(role_id)

ACL

await client.identity.grant({"userId": user_id, "resource": "bucket:users", "permission": "read"})
await client.identity.revoke({"userId": user_id, "resource": "bucket:users", "permission": "read"})
acl = await client.identity.get_acl("bucket", "users")
access = await client.identity.my_access()

Ownership

owner = await client.identity.get_owner("bucket", "users")
await client.identity.transfer_owner("bucket", "users", new_owner_id)

AuditAPI

Access via client.audit.

entries = await client.audit.query({"userId": "admin-1", "limit": 50})

Supported filter keys: userId, operation, result, from, to, limit.


ProceduresAPI

Access via client.procedures.

# Register
await client.procedures.register({"name": "calculate-total", "steps": [...]})

# Execute
result = await client.procedures.call("calculate-total", {"orderId": "123"})

# Admin
proc = await client.procedures.get("calculate-total")
all_procs = await client.procedures.list()
await client.procedures.update("calculate-total", {"steps": [...]})
await client.procedures.unregister("calculate-total")

Error Handling

All errors from the server are propagated as NoexClientError with a machine-readable code:

from noex_client import NoexClientError, RequestTimeoutError, DisconnectedError

try:
    await client.store.bucket("users").insert({"name": ""})
except NoexClientError as e:
    match e.code:
        case "VALIDATION_ERROR":
            print(f"Validation failed: {e.details}")
        case "UNAUTHORIZED":
            print("Need to login first")
        case "NOT_FOUND":
            print("Resource not found")
Error class Code Description
NoexClientError (server code) Base class for all server errors
RequestTimeoutError TIMEOUT Request did not receive a response within request_timeout_ms
DisconnectedError DISCONNECTED Attempted to send while not connected, or connection was lost

Pending requests at the time of a disconnect are rejected with DisconnectedError. They are not retried automatically -- the server does not persist request state across connections and automatic retry of non-idempotent operations (insert, emit) could cause duplicates.


Reconnect Behavior

Reconnect is enabled by default. When the connection drops unexpectedly:

  1. All pending requests are rejected with DisconnectedError
  2. The client enters "reconnecting" state and emits reconnecting events
  3. Exponential backoff with jitter determines the delay between attempts
  4. On successful reconnect:
    • Auto-login is performed (if configured)
    • All active subscriptions are restored with fresh data
    • "reconnected" event is emitted
  5. If max retries are exhausted, the client enters "disconnected" state

Calling disconnect() at any point stops the reconnect loop immediately.


License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

noex_client-0.1.0.tar.gz (38.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

noex_client-0.1.0-py3-none-any.whl (25.0 kB view details)

Uploaded Python 3

File details

Details for the file noex_client-0.1.0.tar.gz.

File metadata

  • Download URL: noex_client-0.1.0.tar.gz
  • Upload date:
  • Size: 38.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for noex_client-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7085b8f8cd5e3ea937e1fe83e41bb5efbb0a30e2593f3047767cdb05cf98bb98
MD5 0d05facc7b05dc90b834c39df3ad1fa2
BLAKE2b-256 37b1e01ba330469dede69b7aaa5862477144bce05773d736bb00c642a1c7929b

See more details on using hashes here.

File details

Details for the file noex_client-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: noex_client-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 25.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for noex_client-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a1f8a5fc244d0da99f7a33903adbc9cf88b90359c68df9a87afa09e3be12cb21
MD5 2e6fa92577a753fe67e918bfeb746232
BLAKE2b-256 8a5559586ba43c2f0c50748498958383f9483ed29f7b52e62f182ec155e79a27

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page