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.

Prerequisites

This client connects to a running noex-server instance. The server runs on Node.js and is installed separately:

npm install @hamicek/noex-server @hamicek/noex-store @hamicek/noex

Minimal server setup (TypeScript/Node.js):

import { NoexServer } from '@hamicek/noex-server';
import { Store } from '@hamicek/noex-store';

const store = await Store.start();
const server = await NoexServer.start({ store, port: 8080 });

For the full server documentation, see the noex-server README.

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", {"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.


Production

In production, always use wss:// (WebSocket over TLS) instead of plain ws://. The noex-server itself does not terminate TLS -- place it behind a reverse proxy (nginx, Caddy) that handles TLS and forwards traffic to the server.

client = NoexClient("wss://api.example.com")

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.1.tar.gz (39.1 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.1-py3-none-any.whl (25.3 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: noex_client-0.1.1.tar.gz
  • Upload date:
  • Size: 39.1 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.1.tar.gz
Algorithm Hash digest
SHA256 ab8c5184f2d8a77837a5a3b716d9dabad5c774763d450abd1c12a7bac8e2d6a7
MD5 3ecfa4a61b70d22c196e2beece6299e4
BLAKE2b-256 8b58260f5621e4c018083a6a43183768b7be6297f6368f89efd9015ffd24039c

See more details on using hashes here.

File details

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

File metadata

  • Download URL: noex_client-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 25.3 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.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a839e9b8dc52b0c65bca0102ddd399ac09637e350525d433cfd9b61934379af0
MD5 895acf8ce91306f062b5e4964e6fb54a
BLAKE2b-256 22982470136df7ecfdc7c90e70c7130610dead5d4b74d5199eaa052377ceea3a

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