SignaKitdocs
SDKs

Python

Full API reference for signakit-flags — async-first Python SDK for server-side feature flags with local evaluation, sync fallback, and full type hints. Compatible with Django, Flask, FastAPI, and plain scripts.

Python SDK

Package: signakit-flags
Registry: PyPI
Minimum Python version: 3.11+

The Python SDK fetches your flag configuration from the SignaKit CDN once on startup, then evaluates all flags locally on each request — no network call per evaluation. It is async-first (httpx) with a full synchronous fallback so it works in Django, Flask, FastAPI, WSGI workers, and plain scripts.


Installation

pip install signakit-flags

Set your SDK key as an environment variable:

SIGNAKIT_SDK_KEY=sk_dev_yourOrgId_yourProjectId_random

Initialization

Create the client once at module level — not inside a request handler or view function. A module-level singleton reuses the fetched config across all requests within the same process lifetime and ensures in-memory exposure deduplication works correctly.

lib/signakit.py
import os
from signakit_flags import SignaKitClient

client = SignaKitClient(sdk_key=os.environ["SIGNAKIT_SDK_KEY"])

Call on_ready() once at application startup — for example in a FastAPI lifespan handler:

from contextlib import asynccontextmanager
from fastapi import FastAPI
from lib.signakit import client

@asynccontextmanager
async def lifespan(app: FastAPI):
    result = await client.on_ready()
    if not result.success:
        print(f"[SignaKit] Config load failed: {result.reason}")
    yield

app = FastAPI(lifespan=lifespan)
lib/signakit.py
import os
from signakit_flags import SignaKitClient

client = SignaKitClient(sdk_key=os.environ["SIGNAKIT_SDK_KEY"])
result = client.on_ready_sync()

if not result.success:
    print(f"[SignaKit] Config load failed: {result.reason}")

Import this module at startup — e.g. in Django's AppConfig.ready() or at the top of your Flask application factory.

Anti-pattern: client per request

# ❌ Never do this — re-fetches config on every request, dedup provides no protection
def my_view(request):
    client = SignaKitClient(sdk_key=os.environ["SIGNAKIT_SDK_KEY"])
    await client.on_ready()
    # ...

Initialize once at module level and import the singleton.

SignaKitClient(sdk_key, **kwargs)

ParameterTypeDefaultDescription
sdk_keystrrequiredYour SignaKit SDK key (sk_dev_… or sk_prod_…)
events_urlstrSignaKit defaultOverride the event ingestion endpoint
async_clienthttpx.AsyncClient | NoneNoneInject a custom async HTTP client (useful for testing)
sync_clienthttpx.Client | NoneNoneInject a custom sync HTTP client (useful for testing)

Raises ValueError if sdk_key is empty or malformed.

The convenience wrapper create_instance(sdk_key) returns SignaKitClient | None — it catches any ValueError and returns None on failure, which can simplify startup code that prefers null-checking to try/except.


Waiting for the client to be ready

Call on_ready() (async) or on_ready_sync() (sync) before evaluating flags. Both fetch the config from the CDN and mark the client ready. Subsequent calls are idempotent — the CDN request uses If-None-Match so unchanged configs return a 304 Not Modified and keep bandwidth minimal.

result = await client.on_ready()

if not result.success:
    print(f"SignaKit failed to load config: {result.reason}")
    # Fall back to defaults — create_user_context() returns None when not ready
result = client.on_ready_sync()

if not result.success:
    print(f"SignaKit failed to load config: {result.reason}")

Neither method raises — failures are returned as OnReadyResult(success=False, reason=str(exc)). After a failed ready, create_user_context() returns None and all flag evaluations return None.

OnReadyResult

FieldTypeDescription
successboolTrue if the config was fetched successfully
reasonstr | NoneError message when success is False, otherwise None

is_ready property

if client.is_ready:
    ctx = client.create_user_context(user_id)

Creating a user context

A SignaKitUserContext represents a specific user. Create one per request with the user's ID and any attributes you want to use in targeting rules.

ctx = client.create_user_context("user-123", {
    "plan": "pro",
    "country": "US",
    "beta_tester": True,
})

if ctx is None:
    # Client is not ready — on_ready() was not called or failed
    ...

create_user_context(user_id, attributes?)

ParameterTypeDescription
user_idstrUnique, stable identifier for the user. Used for deterministic bucketing — the same ID always produces the same variation.
attributesUserAttributes | NoneKey-value pairs matched against your audience targeting rules in the dashboard. UserAttributes is dict[str, str | int | float | bool | list[str]].

Returns SignaKitUserContext | None. Returns None and logs an error if the client is not ready.

$userAgent attribute — pass the request's User-Agent string as $userAgent to enable bot detection. Detected bots receive the off variation and do not fire exposure events, keeping experiment data clean.

ctx = client.create_user_context(user_id, {
    "$userAgent": request.headers.get("User-Agent", ""),
    "plan": "pro",
})

Evaluating a single flag

decision = ctx.decide("new-checkout")

if decision and decision.enabled:
    # Show the new checkout experience
    print(decision.variation_key)  # e.g. "treatment"

decide(flag_key) → Decision | None

Returns a Decision dataclass, or None if the flag does not exist, the client is not ready, or the user does not match any targeting rule.

FieldTypeDescription
flag_keystrThe flag key you passed in
enabledboolWhether the flag is on for this user
variation_keystrWhich variation the user is in ("control", "treatment", or your custom key)
rule_keystr | NoneThe targeting rule that matched, or None for default/disabled paths
rule_typestr | None"ab-test", "multi-armed-bandit", or "targeted"None for default/disabled
variablesdict[str, VariableValue]Resolved variable values for the assigned variation

Always check the return value. A None result means the flag was not found or the user did not match any rule — treat it as the disabled/control state.

decision = ctx.decide("my-flag")
show_feature = decision.enabled if decision is not None else False

decide() is synchronous and CPU-bound — no await needed. Exposure events are dispatched in the background: if a running event loop is detected, the event is scheduled as a coroutine task; otherwise it is sent via the sync transport.


Evaluating all flags at once

decisions = ctx.decide_all()

if decisions.get("new-checkout", {}).enabled:
    ...
if decisions.get("redesigned-nav", {}).enabled:
    ...

decide_all() → Decisions

Returns a dict[str, Decision] keyed by flag key. Only flags where the user matches a targeting rule are included. Archived flags are excluded.

Use decide_all() selectively

Calling decide_all() evaluates every flag in your project and fires an $exposure event for each one the user is bucketed into. In middleware or base view classes that run on every request, only evaluate the flags your route actually needs. Using decide_all() across all traffic is one of the most common causes of unexpected event volume spikes.


Tracking events

Track a conversion or goal event that you've defined in the SignaKit dashboard. track_event is an async method — use await in async contexts.

await ctx.track_event("purchase_completed", value=99.99, metadata={"plan": "pro"})

track_event(event_key, value?, metadata?)

ParameterTypeDescription
event_keystrThe event name, matching an event definition in your project. Truncated to 100 characters.
valuefloat | NoneOptional numeric value for the event (e.g. revenue, score)
metadatadict[str, Any] | NoneOptional freeform metadata. Dropped silently if the serialized JSON exceeds 5,000 bytes.

Returns: Coroutine[None] — fire and forget. Errors are logged but never raised. Bot users are silently skipped.

There is no track_event_sync(). In a WSGI environment where you cannot await, schedule the coroutine from a sync context using asyncio.run() or your framework's task runner:

import asyncio

# In a sync Django view
asyncio.run(ctx.track_event("purchase_completed", value=49.99))

Or spin up an event loop only once and submit tasks to it, depending on your deployment setup.


Type hints

All public types are exported directly from signakit_flags:

from signakit_flags import (
    SignaKitClient,
    SignaKitUserContext,
    Decision,
    Decisions,
    OnReadyResult,
    UserAttributes,
    VariableValue,
    TrackEventOptions,
)

Key type aliases

# User attribute values — the $userAgent key accepts a str
UserAttributes = dict[str, str | int | float | bool | list[str]]

# Variable value — string, number, bool, or a JSON object
VariableValue = str | int | float | bool | dict[str, Any]

# decide_all() return type
Decisions = dict[str, Decision]

Decision dataclass

from dataclasses import dataclass

@dataclass(frozen=True, slots=True)
class Decision:
    flag_key: str
    variation_key: str
    enabled: bool
    rule_key: str | None
    rule_type: str | None          # "ab-test" | "multi-armed-bandit" | "targeted" | None
    variables: dict[str, VariableValue]

The SDK is typed with mypy --strict and ships inline type annotations — no stub package needed.


A/B testing

When a flag is running as an experiment, decide() returns the assigned variation. The SDK automatically dispatches an $exposure event the first time a user is evaluated for that flag.

checkout = ctx.decide("checkout-redesign")

variation = checkout.variation_key if checkout else "control"

if variation == "treatment-a":
    return render_checkout_v2(request)
elif variation == "treatment-b":
    return render_checkout_v3(request)
else:
    return render_legacy_checkout(request)

Track your primary metric when the user converts:

# After the user completes the purchase
await ctx.track_event("purchase_completed", value=order.total)

Targeted delivery

When a flag is configured as a targeted rollout (not an experiment), decide() returns enabled=True for users who match the audience rule. No primary metric is needed and no $exposure event is fired — targeted rules are feature rollouts, not experiments.

beta_access = ctx.decide("beta-dashboard")

if beta_access and beta_access.enabled:
    return redirect("/dashboard/beta")

Bucketing is deterministic — the same user_id always produces the same outcome for the same flag configuration.


Error handling

on_ready() and on_ready_sync() never raise. If the config fetch fails, they return OnReadyResult(success=False, reason=...). After that, create_user_context() returns None, and decide() / decide_all() return None / {} — your code should treat these as the default/off state.

result = await client.on_ready()
if not result.success:
    logger.warning("SignaKit unavailable, using defaults: %s", result.reason)

# This is always safe — create_user_context returns None if not ready
ctx = client.create_user_context(user_id, attributes)
if ctx is None:
    show_feature = False
else:
    decision = ctx.decide("my-flag")
    show_feature = decision.enabled if decision else False

Anti-patterns

PatternProblemFix
SignaKitClient(...) inside a Django view or Flask routeRe-fetches config on every request; dedup is per-instance so every request fires a fresh exposureCreate the client once at module level and import the singleton
Not calling on_ready() / on_ready_sync() before the first decide()create_user_context() returns None if the config hasn't loadedCall on_ready() at application startup, not lazily inside a view
await-ing track_event without an active event loopRuntimeError: no running event loop in sync WSGI viewsUse asyncio.run(ctx.track_event(...)) or restructure around an async framework
decide_all() in a Django middleware or Flask before_requestEvaluates every flag on every request; N flags × all traffic = high event volumeUse ctx.decide("specific-flag") for flags needed per-route
Evaluating a flag inside a loop (e.g. per list item or per row)One exposure per item per render — inflates event countsEvaluate once per user before the loop, apply the result to all items
Ignoring a None return from decide()AttributeError: 'NoneType' object has no attribute 'enabled'Always guard with if decision and decision.enabled or decision.enabled if decision else False

Last updated on

On this page