Python SDK
The Python SDK is a headless server library: it decides entitlements and
placements in-process, with no network call and no persistence beyond
memory. The server holds the user context and a Playbook snapshot and
passes both to the SDK; the SDK decides locally.
It is intentionally a small surface — the same decision core as the TypeScript
SDK’s headless LocalRuntime, exposed as a clean public class. It is not a
port of the browser SDK (no React, no hooks, no decision cache — see
Non-goals).
Install
Section titled “Install”The revturbine package (requires Python ≥ 3.10) is published to PyPI. Install it in a virtual environment:
python3 -m venv .venv.venv/bin/python -m pip install revturbinepy -m venv .venv.venv\Scripts\python.exe -m pip install revturbineUse that environment’s Python to run your app too. A virtual environment also handles PEP 668’s “externally managed environment” restriction without modifying system Python.
Pin to a version whose schema matches the Playbook you deploy — see
Refreshing the Playbook:
python -m pip install "revturbine==<version>"Here python means your virtual environment’s interpreter, as in the commands above.
Quick start
Section titled “Quick start”Construct one SDK per (user_context, exported_config), then check
entitlements and decide placements:
import jsonfrom revturbine import RevTurbineCustomerSdk, UserContext
with open("revturbine.playbook.json") as f: exported_config = json.load(f)
user_context: UserContext = { "tenant_id": "tenant_abc", "user_id": "user_123", # Optional — the user's current plan and usage: "plan_handle": "pro", "usage": {"api_calls": {"used": 900.0, "limit": 1000.0}},}
sdk = RevTurbineCustomerSdk( user_context=user_context, exported_config=exported_config,)
# Entitlement gateresult = sdk.can("advanced_analytics")if not result["allowed"]: raise PermissionError(result["reason"])
# Placement decision (which payload, if any, to show)decision = sdk.get_placement_decision( {"placement_id": "pl_dashboard_upsell", "user_id": "user_123"})if decision["visible"]: render(decision["output"])
# Batch form — same decision path, order-preservingdecisions = sdk.get_placement_decisions( [ {"placement_id": "pl_a", "user_id": "user_123"}, {"placement_id": "pl_b", "user_id": "user_123"}, ])User context
Section titled “User context”| Key | Required | Meaning |
|---|---|---|
tenant_id | ✅ | Tenant identifier. |
user_id | ✅ | Current user identifier. |
plan_handle | — | The user’s current plan handle (feeds the plan + entitlements providers). |
plan_name | — | Display name; defaults to plan_handle. |
usage | — | Per-entitlement overrides: {handle: {"used": float, "limit": float}}. |
trial_status | — | Already-derived trial state, overlaid onto the plan provider. |
payment_failed | — | Hard-failure billing-recovery signal. |
payment_at_risk | — | Soft billing-attention signal. |
tiers | — | Current tier handle per capability_tier entitlement handle. |
segment_ids | — | Tenant segment handles your app already resolved for this user. They count as segment membership for chipped payloads, segment-targeted entitlement rules, and the eligible plan and add-on helpers. |
custom | — | Customer traits that segment predicates evaluate over, e.g. {"sessions": 12}. |
builtin_dimensions | — | Built-in segment dimension values your server sets, e.g. {"subscription_state": "paid"}. This is the only way a built-in rt.<dimension>.<value> segment can match on a server port. |
experiments | — | Experiment-handle to variant-handle assignments, for experiment segments. |
tenant_id and user_id are the only required fields; an empty/missing one
raises ValueError at construction.
A user is in a segment only if segment_ids names it or its predicates match
custom, the plan or builtin_dimensions. Anything else fails closed: a
payload or rule chipped to a segment the SDK can’t confirm is not served. Since
BL-0369 the SDK no longer treats every configured segment as matched.
plan_handle must equal the plan’s unique_handle in the Playbook. Usage keys
must equal entitlement handles, and used is the current absolute balance,
not an increment.
Public surface
Section titled “Public surface”| Method | Returns |
|---|---|
can(handle, context=None) | {status, allowed, reason, ...}; preferred entitlement re-check |
check_entitlement(handle, context=None) | {status, allowed, reason, ...} |
get_placement_decision(input) | {placement_id, visible, output, content, ...} |
get_placement_decisions(inputs) | ordered list of the above |
can() is an alias of check_entitlement(). There is no storage/persistence parameter
— the instance is stateless and in-memory by construction; construct a fresh
one per user context.
Runtime model
Section titled “Runtime model”Unlike the TypeScript SDK (which has revturbine_server / custom_endpoints
/ local_only runtime modes), the Python SDK is local-only by design.
There is no runtime-mode switch and no network path:
Python RevTurbineCustomerSdk | |
|---|---|
| Network | None — decides from the supplied Playbook |
| Persistence | None — InMemoryStorage only; no file/db, no decision cache |
| State | None across calls; no interaction/suppression hydration |
| Inputs | user_context + Playbook, both supplied by the server |
This makes it safe on a request hot path: an entitlement gate is a pure in-process computation, with no decision-API round-trip and no shared mutable state between requests.
Refreshing the Playbook
Section titled “Refreshing the Playbook”The Playbook is a snapshot exported from the RevTurbine control plane.
The SDK never fetches or caches it — your service owns its lifecycle:
- Fetch/export the latest
PlaybookJSON (on boot, on a timer, or on a webhook from the control plane). - Construct a new
RevTurbineCustomerSdkwith the new snapshot. Because the instance is stateless, “refresh” is just “rebuild” — atomically swap the reference your handlers read.
# e.g. behind a lock / atomic reference, refreshed every N minutes_sdk = RevTurbineCustomerSdk(user_context=ctx, exported_config=load_config())Pin the installed SDK tag to a version whose schema matches the
Playbook you deploy; a snapshot newer than the SDK’s schema can decide
differently than your TypeScript frontend.
What is ported
Section titled “What is ported”Segment evaluation and targeting-state derivation from a raw user context are
ported and parity-locked, so a Python gate and a TypeScript render agree given
the same user context — not only the same pre-resolved traits. segment_ids
remains available as an explicit override.
Non-goals
Section titled “Non-goals”The Python SDK is the headless server decision core only. The following are intentionally not ported — they are browser/full-SDK concerns with no server equivalent:
- React / JSX components and hooks (no Python equivalent).
- Browser storage,
localStoragetheme persistence,window.RevTurbine. - HTTP-backed / dual-mode dispatch (
runtime_mode) — the Python library is local-only. identify,dismiss/snooze/convert,track_treatment_interaction,get_trial_status,capture,bootstrap_placement_decisions,get_user_context, decision-cache / interaction-state hydration.
Parity guarantee
Section titled “Parity guarantee”Every public method delegates, with zero added decision logic, to the same decision substrate the TypeScript SDK uses. Our parity suite runs all three ports — TypeScript, Python, and Rust — through identical fixtures and asserts byte-identical normalized output; a divergence is treated as a Python-port bug, never a fixture to loosen. This is what lets you gate in Python and render in TypeScript and trust they decided the same thing.