shipeasy 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
shipeasy/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ from ._client import Client, ExperimentResult
2
+ from ._hash import murmur3
3
+ from .middleware import AnonIdMiddleware, AnonIdASGIMiddleware
4
+
5
+ __all__ = [
6
+ "Client",
7
+ "ExperimentResult",
8
+ "murmur3",
9
+ "AnonIdMiddleware",
10
+ "AnonIdASGIMiddleware",
11
+ ]
12
+ __version__ = "0.3.0"
shipeasy/_anon_id.py ADDED
@@ -0,0 +1,92 @@
1
+ """Anonymous bucketing identity — the cross-SDK ``__se_anon_id`` cookie.
2
+
3
+ Gates and experiments bucket a unit with ``murmur3(salt:unit)``. For a logged-out
4
+ visitor the unit is a stable anonymous id carried in a single first-party cookie
5
+ that EVERY Shipeasy SDK (server + browser) reads and writes, so a server render
6
+ and the browser bucket a fractional rollout identically. The cookie name and
7
+ format are frozen across every language; see
8
+ ``experiment-platform/18-identity-bucketing.md``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import uuid
15
+ from contextvars import ContextVar
16
+ from typing import Optional
17
+
18
+ COOKIE = "__se_anon_id"
19
+ MAX_AGE = 31_536_000 # 1 year, in seconds
20
+
21
+ # The cookie value is client-controllable and feeds bucketing, so a tampered
22
+ # value is treated as absent and a fresh id is minted. UUIDs satisfy this.
23
+ _VALID_RX = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
24
+
25
+ # Per-request id resolved by the middleware. A ContextVar (not thread-local)
26
+ # so it works under both threaded WSGI servers and asyncio/ASGI.
27
+ _current: ContextVar[Optional[str]] = ContextVar("shipeasy_anon_id", default=None)
28
+
29
+
30
+ def mint() -> str:
31
+ """A fresh opaque bucketing id (UUIDv4)."""
32
+ return str(uuid.uuid4())
33
+
34
+
35
+ def is_valid(value: Optional[str]) -> bool:
36
+ return isinstance(value, str) and _VALID_RX.match(value) is not None
37
+
38
+
39
+ def current() -> Optional[str]:
40
+ """The anon id the middleware resolved for the current request, or None.
41
+
42
+ ``Client.get_flag`` / ``get_experiment`` fall back to this as the default
43
+ ``anonymous_id``, so evaluations need no per-call wiring.
44
+ """
45
+ return _current.get()
46
+
47
+
48
+ def set_current(value: Optional[str]):
49
+ """Bind the current-request anon id; returns the reset token."""
50
+ return _current.set(value)
51
+
52
+
53
+ def reset_current(token) -> None:
54
+ _current.reset(token)
55
+
56
+
57
+ def parse_cookie_header(header: Optional[str]) -> dict:
58
+ out: dict = {}
59
+ if not header:
60
+ return out
61
+ for pair in header.split(";"):
62
+ pair = pair.strip()
63
+ if "=" in pair:
64
+ k, v = pair.split("=", 1)
65
+ if k and k not in out:
66
+ out[k] = v
67
+ return out
68
+
69
+
70
+ def read_or_mint(cookie_header: Optional[str]):
71
+ """Return ``(id, minted)`` for a raw Cookie header value."""
72
+ raw = parse_cookie_header(cookie_header).get(COOKIE)
73
+ if is_valid(raw):
74
+ return raw, False
75
+ return mint(), True
76
+
77
+
78
+ def build_set_cookie(value: str, secure: bool) -> str:
79
+ """Format the ``Set-Cookie`` header value per the cross-SDK contract.
80
+
81
+ Non-HttpOnly by design — the browser SDK reads it via ``document.cookie`` to
82
+ bucket identically to the server.
83
+ """
84
+ parts = [
85
+ f"{COOKIE}={value}",
86
+ "Path=/",
87
+ f"Max-Age={MAX_AGE}",
88
+ "SameSite=Lax",
89
+ ]
90
+ if secure:
91
+ parts.append("Secure")
92
+ return "; ".join(parts)
shipeasy/_client.py ADDED
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import threading
6
+ import time
7
+ import urllib.request
8
+ import urllib.error
9
+ from typing import Any, Callable, Mapping, Optional, TypeVar
10
+
11
+ from ._eval import ExperimentResult, eval_experiment, eval_gate
12
+ from ._telemetry import Telemetry, DEFAULT_TELEMETRY_URL
13
+ from . import _anon_id
14
+
15
+ T = TypeVar("T")
16
+ log = logging.getLogger("shipeasy")
17
+
18
+
19
+ def _with_anon_id(user: Mapping[str, Any]) -> Mapping[str, Any]:
20
+ """Default ``anonymous_id`` to the request's ``__se_anon_id`` (set by the
21
+ middleware) when the caller passed no explicit unit. A caller-supplied
22
+ ``user_id``/``anonymous_id`` always wins; with no middleware this is a no-op.
23
+ """
24
+ if user.get("user_id") or user.get("anonymous_id"):
25
+ return user
26
+ anon = _anon_id.current()
27
+ if not anon:
28
+ return user
29
+ merged = dict(user)
30
+ merged["anonymous_id"] = anon
31
+ return merged
32
+
33
+ _DEFAULT_BASE_URL = "https://edge.shipeasy.dev"
34
+ _DEFAULT_POLL_INTERVAL = 30
35
+
36
+
37
+ class Client:
38
+ def __init__(
39
+ self,
40
+ api_key: str,
41
+ base_url: Optional[str] = None,
42
+ *,
43
+ env: str = "prod",
44
+ disable_telemetry: bool = False,
45
+ telemetry_url: Optional[str] = None,
46
+ ) -> None:
47
+ self._api_key = api_key
48
+ self._base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
49
+ # Per-evaluation usage telemetry. ON by default; pass
50
+ # disable_telemetry=True to opt out. See _telemetry.py.
51
+ self._telemetry = Telemetry(
52
+ endpoint=telemetry_url or DEFAULT_TELEMETRY_URL,
53
+ sdk_key=api_key,
54
+ side="server",
55
+ env=env,
56
+ disabled=disable_telemetry,
57
+ )
58
+ self._flags_blob: Optional[dict] = None
59
+ self._exps_blob: Optional[dict] = None
60
+ self._flags_etag: Optional[str] = None
61
+ self._exps_etag: Optional[str] = None
62
+ self._poll_interval = _DEFAULT_POLL_INTERVAL
63
+ self._lock = threading.Lock()
64
+ self._stop = threading.Event()
65
+ self._thread: Optional[threading.Thread] = None
66
+ self._initialized = False
67
+
68
+ def init(self) -> None:
69
+ self._fetch_all()
70
+ self._initialized = True
71
+ self._start_poll()
72
+
73
+ def init_once(self) -> None:
74
+ if self._initialized:
75
+ return
76
+ self._fetch_all()
77
+ self._initialized = True
78
+
79
+ def destroy(self) -> None:
80
+ self._stop.set()
81
+ if self._thread:
82
+ self._thread.join(timeout=1)
83
+ self._thread = None
84
+
85
+ def get_flag(self, name: str, user: Mapping[str, Any]) -> bool:
86
+ self._telemetry.emit("gate", name)
87
+ with self._lock:
88
+ gate = (self._flags_blob or {}).get("gates", {}).get(name)
89
+ if not gate:
90
+ return False
91
+ return eval_gate(gate, _with_anon_id(user))
92
+
93
+ def get_config(
94
+ self, name: str, decode: Optional[Callable[[Any], T]] = None
95
+ ) -> Optional[T]:
96
+ self._telemetry.emit("config", name)
97
+ with self._lock:
98
+ entry = (self._flags_blob or {}).get("configs", {}).get(name)
99
+ if not entry:
100
+ return None
101
+ value = entry.get("value")
102
+ if decode is None:
103
+ return value
104
+ try:
105
+ return decode(value)
106
+ except Exception as e: # noqa: BLE001
107
+ log.warning("get_config(%s) decode failed: %s", name, e)
108
+ return None
109
+
110
+ def get_experiment(
111
+ self,
112
+ name: str,
113
+ user: Mapping[str, Any],
114
+ default_params: T,
115
+ decode: Optional[Callable[[Any], T]] = None,
116
+ ) -> ExperimentResult:
117
+ self._telemetry.emit("experiment", name)
118
+ with self._lock:
119
+ flags_blob = self._flags_blob
120
+ exps_blob = self._exps_blob
121
+ exp = (exps_blob or {}).get("experiments", {}).get(name)
122
+ result = eval_experiment(exp, flags_blob, exps_blob, _with_anon_id(user))
123
+ if result.params is None:
124
+ result.params = default_params
125
+ if result.in_experiment and decode is not None:
126
+ try:
127
+ result.params = decode(result.params)
128
+ except Exception as e: # noqa: BLE001
129
+ log.warning("get_experiment(%s) decode failed: %s", name, e)
130
+ return ExperimentResult(False, "control", default_params)
131
+ return result
132
+
133
+ def track(self, user_id: str, event_name: str, properties: Optional[Mapping[str, Any]] = None) -> None:
134
+ body = {
135
+ "events": [{
136
+ "type": "metric",
137
+ "event_name": event_name,
138
+ "user_id": str(user_id),
139
+ "ts": int(time.time() * 1000),
140
+ **({"properties": dict(properties)} if properties else {}),
141
+ }]
142
+ }
143
+ data = json.dumps(body).encode("utf-8")
144
+ threading.Thread(
145
+ target=self._post_silent,
146
+ args=("/collect", data),
147
+ daemon=True,
148
+ ).start()
149
+
150
+ def _post_silent(self, path: str, data: bytes) -> None:
151
+ try:
152
+ req = urllib.request.Request(
153
+ f"{self._base_url}{path}",
154
+ data=data,
155
+ headers={"X-SDK-Key": self._api_key, "Content-Type": "text/plain"},
156
+ method="POST",
157
+ )
158
+ urllib.request.urlopen(req, timeout=10).read()
159
+ except Exception as e: # noqa: BLE001
160
+ log.warning("track failed: %s", e)
161
+
162
+ def _start_poll(self) -> None:
163
+ def loop() -> None:
164
+ while not self._stop.wait(self._poll_interval):
165
+ try:
166
+ self._fetch_all()
167
+ except Exception as e: # noqa: BLE001
168
+ log.warning("background poll failed: %s", e)
169
+ self._thread = threading.Thread(target=loop, daemon=True)
170
+ self._thread.start()
171
+
172
+ def _fetch_all(self) -> None:
173
+ interval = self._fetch_flags()
174
+ self._fetch_exps()
175
+ if interval and interval != self._poll_interval:
176
+ self._poll_interval = interval
177
+
178
+ def _fetch_flags(self) -> Optional[int]:
179
+ status, headers, body = self._http_get("/sdk/flags", self._flags_etag)
180
+ interval_str = headers.get("X-Poll-Interval") or headers.get("x-poll-interval")
181
+ interval = int(interval_str) if interval_str else None
182
+ if status == 304:
183
+ return interval
184
+ if status != 200:
185
+ raise RuntimeError(f"GET /sdk/flags returned {status}")
186
+ with self._lock:
187
+ etag = headers.get("ETag") or headers.get("etag")
188
+ if etag:
189
+ self._flags_etag = etag
190
+ self._flags_blob = json.loads(body)
191
+ return interval
192
+
193
+ def _fetch_exps(self) -> None:
194
+ status, headers, body = self._http_get("/sdk/experiments", self._exps_etag)
195
+ if status == 304:
196
+ return
197
+ if status != 200:
198
+ raise RuntimeError(f"GET /sdk/experiments returned {status}")
199
+ with self._lock:
200
+ etag = headers.get("ETag") or headers.get("etag")
201
+ if etag:
202
+ self._exps_etag = etag
203
+ self._exps_blob = json.loads(body)
204
+
205
+ def _http_get(self, path: str, etag: Optional[str]) -> tuple[int, Mapping[str, str], bytes]:
206
+ headers = {"X-SDK-Key": self._api_key}
207
+ if etag:
208
+ headers["If-None-Match"] = etag
209
+ req = urllib.request.Request(f"{self._base_url}{path}", headers=headers, method="GET")
210
+ try:
211
+ resp = urllib.request.urlopen(req, timeout=10)
212
+ return resp.status, dict(resp.headers), resp.read()
213
+ except urllib.error.HTTPError as e:
214
+ return e.code, dict(e.headers or {}), e.read() if e.fp else b""
shipeasy/_eval.py ADDED
@@ -0,0 +1,142 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Mapping, Optional
3
+ import re
4
+
5
+ from ._hash import murmur3
6
+
7
+
8
+ @dataclass
9
+ class ExperimentResult:
10
+ in_experiment: bool
11
+ group: str
12
+ params: Optional[Any]
13
+
14
+
15
+ def _enabled(v: Any) -> bool:
16
+ return v == 1 or v is True
17
+
18
+
19
+ def _to_num(v: Any) -> Optional[float]:
20
+ if isinstance(v, bool):
21
+ return None
22
+ if isinstance(v, (int, float)):
23
+ return float(v)
24
+ if isinstance(v, str):
25
+ try:
26
+ return float(v)
27
+ except ValueError:
28
+ return None
29
+ return None
30
+
31
+
32
+ def _user_id(user: Mapping[str, Any]) -> Optional[str]:
33
+ uid = user.get("user_id") or user.get("anonymous_id")
34
+ return str(uid) if uid else None
35
+
36
+
37
+ def match_rule(rule: Mapping[str, Any], user: Mapping[str, Any]) -> bool:
38
+ attr = rule.get("attr")
39
+ op = rule.get("op")
40
+ value = rule.get("value")
41
+ actual = user.get(attr) if attr else None
42
+
43
+ if op == "eq":
44
+ return actual == value
45
+ if op == "neq":
46
+ return actual != value
47
+ if op == "in":
48
+ return actual in (value or [])
49
+ if op == "not_in":
50
+ return actual not in (value or [])
51
+ if op == "contains":
52
+ if isinstance(actual, str) and isinstance(value, str):
53
+ return value in actual
54
+ if isinstance(actual, list):
55
+ return value in actual
56
+ return False
57
+ if op == "regex":
58
+ if isinstance(actual, str) and isinstance(value, str):
59
+ try:
60
+ return re.search(value, actual) is not None
61
+ except re.error:
62
+ return False
63
+ return False
64
+ if op in ("gt", "gte", "lt", "lte"):
65
+ a = _to_num(actual)
66
+ b = _to_num(value)
67
+ if a is None or b is None:
68
+ return False
69
+ if op == "gt":
70
+ return a > b
71
+ if op == "gte":
72
+ return a >= b
73
+ if op == "lt":
74
+ return a < b
75
+ return a <= b
76
+ return False
77
+
78
+
79
+ def eval_gate(gate: Mapping[str, Any], user: Mapping[str, Any]) -> bool:
80
+ if _enabled(gate.get("killswitch")):
81
+ return False
82
+ if not _enabled(gate.get("enabled")):
83
+ return False
84
+ for rule in gate.get("rules") or []:
85
+ if not match_rule(rule, user):
86
+ return False
87
+ uid = _user_id(user)
88
+ if not uid:
89
+ # No unit id (an unidentified request before any anon id is minted): a
90
+ # fully-rolled gate is on for everyone, so it can be answered without
91
+ # bucketing; a fractional rollout genuinely needs a stable unit, so deny
92
+ # until one exists. Rules above are still checked, so targeting wins.
93
+ # See experiment-platform/18-identity-bucketing.md.
94
+ return (gate.get("rolloutPct") or 0) >= 10000
95
+ salt = gate.get("salt") or ""
96
+ return murmur3(f"{salt}:{uid}") % 10000 < (gate.get("rolloutPct") or 0)
97
+
98
+
99
+ _NOT_IN = ExperimentResult(in_experiment=False, group="control", params=None)
100
+
101
+
102
+ def eval_experiment(
103
+ exp: Optional[Mapping[str, Any]],
104
+ flags_blob: Optional[Mapping[str, Any]],
105
+ exps_blob: Optional[Mapping[str, Any]],
106
+ user: Mapping[str, Any],
107
+ ) -> ExperimentResult:
108
+ if not exp or exp.get("status") != "running":
109
+ return _NOT_IN
110
+
111
+ targeting_gate = exp.get("targetingGate")
112
+ if targeting_gate:
113
+ gate = (flags_blob or {}).get("gates", {}).get(targeting_gate)
114
+ if not gate or not eval_gate(gate, user):
115
+ return _NOT_IN
116
+
117
+ uid = _user_id(user)
118
+ if not uid:
119
+ return _NOT_IN
120
+
121
+ universe_name = exp.get("universe")
122
+ universe = (exps_blob or {}).get("universes", {}).get(universe_name) if universe_name else None
123
+ holdout = universe.get("holdout_range") if universe else None
124
+ if holdout:
125
+ seg = murmur3(f"{universe_name}:{uid}") % 10000
126
+ if holdout[0] <= seg <= holdout[1]:
127
+ return _NOT_IN
128
+
129
+ salt = exp.get("salt") or ""
130
+ alloc_pct = exp.get("allocationPct") or 0
131
+ if murmur3(f"{salt}:alloc:{uid}") % 10000 >= alloc_pct:
132
+ return _NOT_IN
133
+
134
+ group_hash = murmur3(f"{salt}:group:{uid}") % 10000
135
+ cumulative = 0
136
+ groups = exp.get("groups") or []
137
+ for i, g in enumerate(groups):
138
+ cumulative += g.get("weight", 0)
139
+ if group_hash < cumulative or i == len(groups) - 1:
140
+ return ExperimentResult(in_experiment=True, group=g.get("name", "control"), params=g.get("params"))
141
+
142
+ return _NOT_IN
shipeasy/_hash.py ADDED
@@ -0,0 +1,49 @@
1
+ _MASK32 = 0xFFFFFFFF
2
+ _C1 = 0xCC9E2D51
3
+ _C2 = 0x1B873593
4
+
5
+
6
+ def _rotl(x: int, r: int) -> int:
7
+ return ((x << r) | (x >> (32 - r))) & _MASK32
8
+
9
+
10
+ def _fmix32(h: int) -> int:
11
+ h ^= h >> 16
12
+ h = (h * 0x85EBCA6B) & _MASK32
13
+ h ^= h >> 13
14
+ h = (h * 0xC2B2AE35) & _MASK32
15
+ h ^= h >> 16
16
+ return h
17
+
18
+
19
+ def murmur3(key: str, seed: int = 0) -> int:
20
+ data = key.encode("utf-8")
21
+ n = len(data)
22
+ h1 = seed & _MASK32
23
+ nblocks = n // 4
24
+ for i in range(nblocks):
25
+ off = i * 4
26
+ k1 = data[off] | (data[off + 1] << 8) | (data[off + 2] << 16) | (data[off + 3] << 24)
27
+ k1 = (k1 * _C1) & _MASK32
28
+ k1 = _rotl(k1, 15)
29
+ k1 = (k1 * _C2) & _MASK32
30
+ h1 ^= k1
31
+ h1 = _rotl(h1, 13)
32
+ h1 = ((h1 * 5) + 0xE6546B64) & _MASK32
33
+
34
+ tail_idx = nblocks * 4
35
+ k1 = 0
36
+ rem = n & 3
37
+ if rem >= 3:
38
+ k1 ^= data[tail_idx + 2] << 16
39
+ if rem >= 2:
40
+ k1 ^= data[tail_idx + 1] << 8
41
+ if rem >= 1:
42
+ k1 ^= data[tail_idx]
43
+ k1 = (k1 * _C1) & _MASK32
44
+ k1 = _rotl(k1, 15)
45
+ k1 = (k1 * _C2) & _MASK32
46
+ h1 ^= k1
47
+
48
+ h1 ^= n
49
+ return _fmix32(h1)
shipeasy/_telemetry.py ADDED
@@ -0,0 +1,67 @@
1
+ """Per-evaluation usage telemetry.
2
+
3
+ Fires one fire-and-forget HTTP beacon per evaluation so usage is counted by
4
+ Cloudflare's native per-path analytics (zero storage on our side). Mirrors the
5
+ contract in the TypeScript reference SDK and experiment-platform/15-usage-metering.md.
6
+
7
+ The path carries sha256(sdk_key) -- never the raw key, so a secret server key
8
+ never lands in edge logs -- plus side/env, then feature/resource. A long-lived
9
+ Python process can emit reliably (unlike Cloudflare Workers), so a daemon thread
10
+ per beacon is fine; the 2s dedup window bounds volume under render/loop storms.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import threading
16
+ import time
17
+ import urllib.request
18
+ from urllib.parse import quote
19
+ from typing import Dict
20
+
21
+ DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai"
22
+ _FEATURES = frozenset({"gate", "config", "ks", "experiment", "event"})
23
+
24
+
25
+ class Telemetry:
26
+ def __init__(
27
+ self,
28
+ endpoint: str,
29
+ sdk_key: str,
30
+ side: str = "server",
31
+ env: str = "prod",
32
+ disabled: bool = False,
33
+ dedupe_ms: int = 2000,
34
+ ) -> None:
35
+ endpoint = (endpoint or "").rstrip("/")
36
+ self._disabled = disabled or not sdk_key or not endpoint
37
+ self._dedupe_ms = dedupe_ms
38
+ self._last: Dict[str, float] = {}
39
+ self._lock = threading.Lock()
40
+ if self._disabled:
41
+ self._prefix = ""
42
+ else:
43
+ key_hash = hashlib.sha256(sdk_key.encode("utf-8")).hexdigest()
44
+ self._prefix = f"{endpoint}/t/{key_hash}/{side}/{quote(env, safe='')}"
45
+
46
+ def emit(self, feature: str, resource: str) -> None:
47
+ """Best-effort usage beacon for one evaluation. Never blocks, never raises."""
48
+ if self._disabled:
49
+ return
50
+ if self._dedupe_ms > 0:
51
+ dedupe_key = f"{feature}/{resource}"
52
+ now = time.monotonic() * 1000.0
53
+ with self._lock:
54
+ last = self._last.get(dedupe_key)
55
+ if last is not None and now - last < self._dedupe_ms:
56
+ return
57
+ self._last[dedupe_key] = now
58
+ url = f"{self._prefix}/{feature}/{quote(resource, safe='')}"
59
+ threading.Thread(target=_send, args=(url,), daemon=True).start()
60
+
61
+
62
+ def _send(url: str) -> None:
63
+ try:
64
+ req = urllib.request.Request(url, method="GET")
65
+ urllib.request.urlopen(req, timeout=2).close()
66
+ except Exception: # noqa: BLE001 -- telemetry must never affect the caller
67
+ pass
shipeasy/middleware.py ADDED
@@ -0,0 +1,87 @@
1
+ """Drop-in WSGI / ASGI middleware that mints the shared ``__se_anon_id`` cookie.
2
+
3
+ For any request without a valid ``__se_anon_id`` cookie it mints a UUIDv4,
4
+ exposes it for the duration of the request, and ``Set-Cookie``s it on the
5
+ response. Once installed, gate/experiment evaluations with no explicit
6
+ ``user_id``/``anonymous_id`` automatically bucket on the cookie id — anonymous
7
+ visitors get stable, SSR/browser-consistent bucketing with zero per-call wiring.
8
+
9
+ WSGI (Flask, Django, any WSGI app)::
10
+
11
+ from shipeasy.middleware import AnonIdMiddleware
12
+ app.wsgi_app = AnonIdMiddleware(app.wsgi_app)
13
+
14
+ ASGI (FastAPI, Starlette)::
15
+
16
+ from shipeasy.middleware import AnonIdASGIMiddleware
17
+ app.add_middleware(AnonIdASGIMiddleware)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Callable
23
+
24
+ from . import _anon_id as anon_id
25
+
26
+
27
+ class AnonIdMiddleware:
28
+ """WSGI middleware."""
29
+
30
+ def __init__(self, app: Callable) -> None:
31
+ self.app = app
32
+
33
+ def __call__(self, environ, start_response):
34
+ anon, minted = anon_id.read_or_mint(environ.get("HTTP_COOKIE"))
35
+ environ["shipeasy.anon_id"] = anon
36
+ token = anon_id.set_current(anon)
37
+
38
+ def _start_response(status, headers, exc_info=None):
39
+ if minted:
40
+ secure = environ.get("wsgi.url_scheme") == "https" or (
41
+ environ.get("HTTP_X_FORWARDED_PROTO", "").split(",")[0].strip() == "https"
42
+ )
43
+ headers = list(headers) + [("Set-Cookie", anon_id.build_set_cookie(anon, secure))]
44
+ return start_response(status, headers, exc_info)
45
+
46
+ try:
47
+ return self.app(environ, _start_response)
48
+ finally:
49
+ anon_id.reset_current(token)
50
+
51
+
52
+ class AnonIdASGIMiddleware:
53
+ """Pure-ASGI middleware (HTTP scope only; other scopes pass through)."""
54
+
55
+ def __init__(self, app) -> None:
56
+ self.app = app
57
+
58
+ async def __call__(self, scope, receive, send):
59
+ if scope.get("type") != "http":
60
+ await self.app(scope, receive, send)
61
+ return
62
+
63
+ header = b"".join(
64
+ v for k, v in scope.get("headers", []) if k == b"cookie"
65
+ ).decode("latin-1") or None
66
+ anon, minted = anon_id.read_or_mint(header)
67
+ token = anon_id.set_current(anon)
68
+
69
+ async def _send(message):
70
+ if minted and message["type"] == "http.response.start":
71
+ secure = scope.get("scheme") == "https" or _xfp_https(scope)
72
+ cookie = anon_id.build_set_cookie(anon, secure).encode("latin-1")
73
+ message = dict(message)
74
+ message["headers"] = list(message.get("headers", [])) + [(b"set-cookie", cookie)]
75
+ await send(message)
76
+
77
+ try:
78
+ await self.app(scope, receive, _send)
79
+ finally:
80
+ anon_id.reset_current(token)
81
+
82
+
83
+ def _xfp_https(scope) -> bool:
84
+ for k, v in scope.get("headers", []):
85
+ if k == b"x-forwarded-proto":
86
+ return v.decode("latin-1").split(",")[0].strip() == "https"
87
+ return False
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: shipeasy
3
+ Version: 0.3.0
4
+ Summary: Shipeasy server SDK for Python — feature flags, configs, experiments, metrics.
5
+ Project-URL: Homepage, https://shipeasy.dev
6
+ Project-URL: Source, https://github.com/shipeasy-ai/sdk-python
7
+ Author: Shipeasy
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # shipeasy (Python)
17
+
18
+ Server SDK for [Shipeasy](https://shipeasy.dev) — feature flags, remote configs, A/B experiments, and metric tracking. Server-key only, never embed in browsers.
19
+
20
+ ```bash
21
+ pip install shipeasy
22
+ ```
23
+
24
+ ```python
25
+ from shipeasy import Client
26
+
27
+ client = Client(api_key="sdk_server_...")
28
+ client.init() # background poll; use init_once() for serverless
29
+
30
+ if client.get_flag("new_checkout", {"user_id": "u_123", "country": "US"}):
31
+ ...
32
+
33
+ config = client.get_config("billing_copy")
34
+
35
+ result = client.get_experiment(
36
+ "checkout_button",
37
+ user={"user_id": "u_123"},
38
+ default_params={"color": "blue"},
39
+ )
40
+ print(result.in_experiment, result.group, result.params)
41
+
42
+ client.track("u_123", "purchase", {"amount": 49})
43
+ ```
44
+
45
+ ## Anonymous visitors (zero-config bucketing)
46
+
47
+ For logged-out traffic you need a *stable* unit so a fractional rollout buckets
48
+ the same on the server and in the browser. The middleware mints a first-party
49
+ `__se_anon_id` cookie (shared with every Shipeasy SDK) for any request without
50
+ one; evaluations then **default to it** as `anonymous_id`, so `get_flag` on an
51
+ anonymous request just works — no per-call wiring.
52
+
53
+ ```python
54
+ # WSGI (Flask, Django, ...)
55
+ from shipeasy.middleware import AnonIdMiddleware
56
+ app.wsgi_app = AnonIdMiddleware(app.wsgi_app)
57
+
58
+ # ASGI (FastAPI, Starlette)
59
+ from shipeasy.middleware import AnonIdASGIMiddleware
60
+ app.add_middleware(AnonIdASGIMiddleware)
61
+ ```
62
+
63
+ ```python
64
+ # logged-out request → buckets on the __se_anon_id cookie automatically
65
+ client.get_flag("new_checkout", {})
66
+ ```
67
+
68
+ An explicit `user_id`/`anonymous_id` always wins. The id is also on the request
69
+ (`environ["shipeasy.anon_id"]`). The cookie is non-`HttpOnly` by design so the
70
+ browser SDK buckets identically; a request with **no** unit still resolves a
71
+ fully-rolled (100%) gate as on. Cookie name + format are a cross-SDK contract —
72
+ see `18-identity-bucketing.md`.
73
+
74
+ ## Evaluation
75
+
76
+ Tested against the cross-language MurmurHash3 vectors in `experiment-platform/04-evaluation.md`.
@@ -0,0 +1,11 @@
1
+ shipeasy/__init__.py,sha256=GCSaKkIvpm764bwJ2ZP39iM7IfpO8cCdZH4dnTlRWx0,278
2
+ shipeasy/_anon_id.py,sha256=Cs1UnhZtQb2e-sXYwutEAgqIx3PhYrLkAUZD6P5imY8,2836
3
+ shipeasy/_client.py,sha256=_8-91KqxX94G7klTsxx_avgNVlQyZlkCMdRM1NFYiHw,7666
4
+ shipeasy/_eval.py,sha256=nR0iNBBF6_160VhBjSzZ3SJ3QV6_m5aIlSQMUmBuMfk,4363
5
+ shipeasy/_hash.py,sha256=jdmuFswvqEhe0-rDLm5d6s8mryBZQJgPsxA70W7CyH4,1135
6
+ shipeasy/_telemetry.py,sha256=gd5B6sXYBJV7LhpFA92_DXBEdeUheTmXVeWMzWgiLp0,2490
7
+ shipeasy/middleware.py,sha256=wezm4JQ3YkOF4hj0dA4OrKJNsKDRGeBijTdkx56yvNw,3041
8
+ shipeasy-0.3.0.dist-info/METADATA,sha256=_oz2JMp3Ze40exlgjtwt9C_L-d9NV9JI-yqxWSBX65I,2510
9
+ shipeasy-0.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
10
+ shipeasy-0.3.0.dist-info/licenses/LICENSE,sha256=4vaJafqxp44-g9UsxZJOp19rUk9X65skmXxOYIClIY8,1651
11
+ shipeasy-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,40 @@
1
+ Shipeasy Source-Available License (Shipeasy-SAL) 1.0
2
+
3
+ Copyright (c) 2026 Shipeasy, Inc. All rights reserved.
4
+
5
+ 1. License Grant.
6
+ Subject to the terms of this License, Shipeasy, Inc. ("Shipeasy") grants
7
+ you a non-exclusive, non-transferable, revocable, worldwide license to:
8
+
9
+ (a) Use, copy, and modify the Software solely as a client integration for
10
+ interacting with Shipeasy's hosted services (the "Service");
11
+ (b) Distribute the Software as part of an application that calls the
12
+ Service, in object form, provided the recipient also agrees to this
13
+ License.
14
+
15
+ 2. Restrictions.
16
+ You may not:
17
+
18
+ (a) Use the Software, in whole or in part, to build, host, or operate any
19
+ service that competes with the Service or that provides feature-flag,
20
+ experimentation, configuration, internationalization, or related
21
+ functionality to third parties on a commercial basis;
22
+ (b) Sublicense, sell, rent, or lease the Software;
23
+ (c) Remove or alter copyright notices, license terms, or attribution.
24
+
25
+ 3. Contributions.
26
+ Any pull request you submit is licensed back to Shipeasy under this
27
+ License plus a perpetual, irrevocable right for Shipeasy to relicense.
28
+
29
+ 4. Trademarks.
30
+ This License does not grant rights in the names "Shipeasy", related
31
+ marks, or logos.
32
+
33
+ 5. No Warranty / Limitation of Liability.
34
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. IN NO
35
+ EVENT SHALL SHIPEASY BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
36
+ LIABILITY ARISING FROM USE OF THE SOFTWARE.
37
+
38
+ 6. Termination.
39
+ This License terminates automatically if you breach it. Sections 2-5
40
+ survive termination.