Metadata-Version: 2.4
Name: shipeasy
Version: 0.7.0
Summary: Shipeasy server SDK for Python — feature flags, configs, experiments, metrics.
Project-URL: Homepage, https://shipeasy.dev
Project-URL: Source, https://github.com/shipeasy-ai/sdk-python
Author: Shipeasy
License: MIT
License-File: LICENSE
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.9
Provides-Extra: openfeature
Requires-Dist: openfeature-sdk>=0.7; extra == 'openfeature'
Description-Content-Type: text/markdown

# shipeasy (Python)

Server SDK for [Shipeasy](https://shipeasy.dev) — feature flags, remote configs, A/B experiments, and metric tracking. Server-key only, never embed in browsers.

```bash
pip install shipeasy
```

```python
from shipeasy import Client

client = Client(api_key="sdk_server_...")
client.init()  # background poll; use init_once() for serverless

if client.get_flag("new_checkout", {"user_id": "u_123", "country": "US"}):
    ...

config = client.get_config("billing_copy")

result = client.get_experiment(
    "checkout_button",
    user={"user_id": "u_123"},
    default_params={"color": "blue"},
)
print(result.in_experiment, result.group, result.params)

client.track("u_123", "purchase", {"amount": 49})
```

## Anonymous visitors (zero-config bucketing)

For logged-out traffic you need a *stable* unit so a fractional rollout buckets
the same on the server and in the browser. The middleware mints a first-party
`__se_anon_id` cookie (shared with every Shipeasy SDK) for any request without
one; evaluations then **default to it** as `anonymous_id`, so `get_flag` on an
anonymous request just works — no per-call wiring.

```python
# WSGI (Flask, Django, ...)
from shipeasy.middleware import AnonIdMiddleware
app.wsgi_app = AnonIdMiddleware(app.wsgi_app)

# ASGI (FastAPI, Starlette)
from shipeasy.middleware import AnonIdASGIMiddleware
app.add_middleware(AnonIdASGIMiddleware)
```

```python
# logged-out request → buckets on the __se_anon_id cookie automatically
client.get_flag("new_checkout", {})
```

An explicit `user_id`/`anonymous_id` always wins. The id is also on the request
(`environ["shipeasy.anon_id"]`). The cookie is non-`HttpOnly` by design so the
browser SDK buckets identically; a request with **no** unit still resolves a
fully-rolled (100%) gate as on. Cookie name + format are a cross-SDK contract —
see `18-identity-bucketing.md`.

## Server-side rendering (SSR)

Emit the request's evaluated flags as a declarative `<script>` tag so the
browser SDK has them on first paint. `bootstrap_script_tag` carries the payload
in `data-*` attributes (**no key**); the static `se-bootstrap.js` loader
hydrates `window.__SE_BOOTSTRAP` and writes the `__se_anon_id` cookie so the
browser buckets identically to the server.

```python
user = {"user_id": "u_123"}

# Two tags for the document <head>. The PUBLIC client key (not the server
# key) goes on the i18n loader tag.
head = client.bootstrap_script_tag(user, anon_id=anon_id) \
     + client.i18n_script_tag(client_key, "en:prod")

# …or get the raw payload ({"flags", "configs", "experiments", "killswitches"}):
boot = client.evaluate(user)
```

`bootstrap_script_tag` also accepts `i18n_profile=` and `base_url=`
(defaults to `https://cdn.shipeasy.ai`).

## Default values

`get_flag` and `get_config` take a `default` that is returned only when the
value **cannot be evaluated** — never when it simply resolves off:

```python
# default is returned only if the client isn't initialized OR the gate isn't
# in the blob. A gate that evaluates to False returns False, not the default.
client.get_flag("new_checkout", {"user_id": "u_123"}, default=True)

# default is returned when the config key is absent (or decode raises).
client.get_config("billing_copy", default={"title": "Welcome"})
client.get_config("limits", decode=lambda v: v["max"], default=0)
```

## Evaluation detail

`get_flag_detail` returns a `FlagDetail(value, reason)` so you can log *why* a
flag resolved the way it did. `reason` is one of the exported constants:

```python
from shipeasy import (
    FlagDetail, CLIENT_NOT_READY, FLAG_NOT_FOUND, OFF, OVERRIDE, RULE_MATCH, DEFAULT,
)

d = client.get_flag_detail("new_checkout", {"user_id": "u_123"})
print(d.value, d.reason)  # e.g. True RULE_MATCH
```

| reason | meaning |
| --- | --- |
| `OVERRIDE` | a local `override_flag` forced the value (no telemetry) |
| `CLIENT_NOT_READY` | `init()`/`init_once()` hasn't run yet → `value=False` |
| `FLAG_NOT_FOUND` | no gate by that name in the blob → `value=False` |
| `OFF` | the gate exists but is disabled → `value=False` |
| `RULE_MATCH` | evaluated **on** (targeting + rollout) |
| `DEFAULT` | evaluated **off** (fell through) |

`get_flag` delegates to `get_flag_detail` and returns `.value` (substituting
`default` for `CLIENT_NOT_READY`/`FLAG_NOT_FOUND`).

## Change listeners

Register a callback fired after a background poll fetches **new** data (a 200,
not a 304). It returns an unsubscribe callable. Listeners never fire in
test/offline mode.

```python
unsubscribe = client.on_change(lambda: print("flags changed, rebuild cache"))
...
unsubscribe()  # stop listening
```

## Offline snapshot

Run fully offline from a JSON snapshot — handy for tests, local dev, or
air-gapped CI. Evaluations run the **real** eval logic against the snapshot;
no network is ever touched (`init()`/`init_once()`/`track()` are no-ops) and
`override_*` setters still apply on top.

```python
# From a file: { "flags": <body of /sdk/flags>, "experiments": <body of /sdk/experiments> }
client = Client.from_file("shipeasy-snapshot.json")

# Or from in-memory blobs
client = Client.from_snapshot(
    flags={"gates": {...}, "configs": {...}},
    experiments={"experiments": {...}, "universes": {...}},
)

client.get_flag("new_checkout", {"user_id": "u_123"})
```

## Testing

Use `Client.for_testing()` for unit tests: it does **zero network**, needs no
api_key, disables telemetry, and makes `init()`/`init_once()`/`track()` no-ops.
Seed every entity with the `override_*` setters (Statsig-style local overrides) —
an override always wins over whatever the client would otherwise resolve.

```python
from shipeasy import Client

client = Client.for_testing()  # no key, no network, immediately usable

# Flags
client.override_flag("new_checkout", True)
assert client.get_flag("new_checkout", {"user_id": "u_123"}) is True

# Configs (decode is optional and still applies)
client.override_config("billing_copy", {"title": "Welcome"})
assert client.get_config("billing_copy") == {"title": "Welcome"}
assert client.get_config("billing_copy", decode=lambda v: v["title"]) == "Welcome"

# Experiments → ExperimentResult(in_experiment=True, group=..., params=...)
client.override_experiment("checkout_button", group="treatment", params={"color": "green"})
result = client.get_experiment(
    "checkout_button",
    user={"user_id": "u_123"},
    default_params={"color": "blue"},
)
assert result.in_experiment and result.group == "treatment"
assert result.params == {"color": "green"}

# track() is a no-op in test mode — safe to call, sends nothing
client.track("u_123", "purchase", {"amount": 49})

# Reset between cases
client.clear_overrides()
```

The same `override_*` / `clear_overrides()` setters also work on a normal
`Client` if you want to pin a value in a live client.

## Evaluation

Tested against the cross-language MurmurHash3 vectors in `experiment-platform/04-evaluation.md`.
