shipeasy 0.3.0__tar.gz

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.
@@ -0,0 +1,65 @@
1
+ name: Publish
2
+
3
+ # HARD RULE: never publish manually. Bump the `version` field in
4
+ # pyproject.toml, push to main, and this workflow handles PyPI.
5
+ on:
6
+ push:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ concurrency:
11
+ group: publish
12
+ cancel-in-progress: false
13
+
14
+ jobs:
15
+ publish:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: read
19
+ id-token: write # PyPI Trusted Publishing
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.11"
26
+
27
+ - run: pip install build hatchling
28
+
29
+ - name: Run tests
30
+ run: |
31
+ pip install pytest -e .
32
+ pytest -q
33
+
34
+ - name: Read package version
35
+ id: ver
36
+ run: |
37
+ VER=$(python -c "import tomllib,sys; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
38
+ echo "version=$VER" >> "$GITHUB_OUTPUT"
39
+
40
+ - name: Check if version already on PyPI
41
+ id: check
42
+ run: |
43
+ STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://pypi.org/pypi/shipeasy/${{ steps.ver.outputs.version }}/json)
44
+ if [ "$STATUS" = "200" ]; then
45
+ echo "Already published: shipeasy==${{ steps.ver.outputs.version }}"
46
+ echo "skip=1" >> "$GITHUB_OUTPUT"
47
+ else
48
+ echo "skip=0" >> "$GITHUB_OUTPUT"
49
+ fi
50
+
51
+ - name: Build distributions
52
+ if: steps.check.outputs.skip != '1'
53
+ run: python -m build
54
+
55
+ # Requires PyPI Trusted Publishing to be configured for the
56
+ # `shipeasy` project on pypi.org → repo `shipeasy-ai/sdk-python`,
57
+ # workflow `publish.yml`. Set vars.PUBLISH_ENABLED=true once
58
+ # trusted publisher is configured.
59
+ - name: Publish to PyPI (Trusted Publishing)
60
+ if: steps.check.outputs.skip != '1' && vars.PUBLISH_ENABLED == 'true'
61
+ uses: pypa/gh-action-pypi-publish@release/v1
62
+
63
+ - name: Publish skipped notice
64
+ if: steps.check.outputs.skip != '1' && vars.PUBLISH_ENABLED != 'true'
65
+ run: echo "::warning::vars.PUBLISH_ENABLED is not 'true' — configure PyPI Trusted Publishing on this repo, then set the variable."
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ - **Anonymous bucketing (`__se_anon_id`).** Added `AnonIdMiddleware` (WSGI) and
6
+ `AnonIdASGIMiddleware` (ASGI) — zero-dependency middleware that mints the
7
+ shared `__se_anon_id` first-party cookie for any request without one and
8
+ exposes it on the request (`environ["shipeasy.anon_id"]`). Gate/experiment
9
+ evaluations now default to the cookie id as `anonymous_id` (via a `ContextVar`,
10
+ so it works under threads and asyncio), so anonymous visitors bucket
11
+ consistently across server renders and the browser with no per-call wiring.
12
+ Implements the cross-SDK contract in `18-identity-bucketing.md`.
13
+ - **Eval fix (no-unit gate rule).** A request with no `user_id`/`anonymous_id`
14
+ now resolves a fully-rolled (100%) gate as **on** instead of always off; a
15
+ fractional gate is still off until a stable unit exists. Matches the
16
+ TypeScript reference SDK. Targeting rules are still evaluated first.
17
+
18
+ ## 0.2.0
19
+
20
+ - Per-evaluation usage telemetry (fire-and-forget, on by default).
21
+
22
+ ## 0.1.0
23
+
24
+ - Initial release: feature flags, configs, experiments, metric tracking.
shipeasy-0.3.0/LICENSE ADDED
@@ -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.
@@ -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,61 @@
1
+ # shipeasy (Python)
2
+
3
+ Server SDK for [Shipeasy](https://shipeasy.dev) — feature flags, remote configs, A/B experiments, and metric tracking. Server-key only, never embed in browsers.
4
+
5
+ ```bash
6
+ pip install shipeasy
7
+ ```
8
+
9
+ ```python
10
+ from shipeasy import Client
11
+
12
+ client = Client(api_key="sdk_server_...")
13
+ client.init() # background poll; use init_once() for serverless
14
+
15
+ if client.get_flag("new_checkout", {"user_id": "u_123", "country": "US"}):
16
+ ...
17
+
18
+ config = client.get_config("billing_copy")
19
+
20
+ result = client.get_experiment(
21
+ "checkout_button",
22
+ user={"user_id": "u_123"},
23
+ default_params={"color": "blue"},
24
+ )
25
+ print(result.in_experiment, result.group, result.params)
26
+
27
+ client.track("u_123", "purchase", {"amount": 49})
28
+ ```
29
+
30
+ ## Anonymous visitors (zero-config bucketing)
31
+
32
+ For logged-out traffic you need a *stable* unit so a fractional rollout buckets
33
+ the same on the server and in the browser. The middleware mints a first-party
34
+ `__se_anon_id` cookie (shared with every Shipeasy SDK) for any request without
35
+ one; evaluations then **default to it** as `anonymous_id`, so `get_flag` on an
36
+ anonymous request just works — no per-call wiring.
37
+
38
+ ```python
39
+ # WSGI (Flask, Django, ...)
40
+ from shipeasy.middleware import AnonIdMiddleware
41
+ app.wsgi_app = AnonIdMiddleware(app.wsgi_app)
42
+
43
+ # ASGI (FastAPI, Starlette)
44
+ from shipeasy.middleware import AnonIdASGIMiddleware
45
+ app.add_middleware(AnonIdASGIMiddleware)
46
+ ```
47
+
48
+ ```python
49
+ # logged-out request → buckets on the __se_anon_id cookie automatically
50
+ client.get_flag("new_checkout", {})
51
+ ```
52
+
53
+ An explicit `user_id`/`anonymous_id` always wins. The id is also on the request
54
+ (`environ["shipeasy.anon_id"]`). The cookie is non-`HttpOnly` by design so the
55
+ browser SDK buckets identically; a request with **no** unit still resolves a
56
+ fully-rolled (100%) gate as on. Cookie name + format are a cross-SDK contract —
57
+ see `18-identity-bucketing.md`.
58
+
59
+ ## Evaluation
60
+
61
+ Tested against the cross-language MurmurHash3 vectors in `experiment-platform/04-evaluation.md`.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "shipeasy"
7
+ version = "0.3.0"
8
+ description = "Shipeasy server SDK for Python — feature flags, configs, experiments, metrics."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "Shipeasy" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://shipeasy.dev"
21
+ Source = "https://github.com/shipeasy-ai/sdk-python"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["shipeasy"]
@@ -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"
@@ -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)
@@ -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""
@@ -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
@@ -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)
@@ -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
@@ -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,31 @@
1
+ from shipeasy._eval import eval_gate
2
+
3
+
4
+ # The no-unit evaluation rule is a cross-SDK contract: a request with no unit id
5
+ # answers a fully-rolled gate as on (no bucketing needed) but a fractional gate
6
+ # as off. See experiment-platform/18-identity-bucketing.md.
7
+ def test_no_unit_full_rollout_is_on():
8
+ assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 10000}, {}) is True
9
+
10
+
11
+ def test_no_unit_fractional_is_off():
12
+ assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 5000}, {}) is False
13
+
14
+
15
+ def test_no_unit_disabled_or_killed_is_off():
16
+ assert eval_gate({"enabled": 0, "rolloutPct": 10000}, {}) is False
17
+ assert eval_gate({"enabled": 1, "killswitch": 1, "rolloutPct": 10000}, {}) is False
18
+
19
+
20
+ def test_no_unit_targeting_rule_wins():
21
+ gate = {
22
+ "enabled": 1, "salt": "s", "rolloutPct": 10000,
23
+ "rules": [{"attr": "plan", "op": "eq", "value": "pro"}],
24
+ }
25
+ assert eval_gate(gate, {}) is False
26
+ assert eval_gate(gate, {"plan": "pro"}) is True
27
+
28
+
29
+ def test_with_unit_unchanged():
30
+ assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 0}, {"user_id": "u1"}) is False
31
+ assert eval_gate({"enabled": 1, "salt": "s", "rolloutPct": 10000}, {"user_id": "u1"}) is True
@@ -0,0 +1,20 @@
1
+ from shipeasy import murmur3
2
+
3
+
4
+ def test_vectors():
5
+ # Verified against the Ruby SDK reference impl. Cross-language
6
+ # consistency is the contract; the table in
7
+ # experiment-platform/04-evaluation.md disagrees on some inputs and
8
+ # appears to be unverified.
9
+ cases = [
10
+ ("", 0x00000000),
11
+ ("a", 0x3c2569b2),
12
+ ("ab", 0x9bbfd75f),
13
+ ("abc", 0xb3dd93fa),
14
+ ("aaaa", 0x7eeed987),
15
+ ("aaaaa", 0xe9ca302b),
16
+ ("Hello, 世界", 0xe2a131eb),
17
+ ("The quick brown fox jumps over the lazy dog", 0x2e4ff723),
18
+ ]
19
+ for inp, expected in cases:
20
+ assert murmur3(inp) == expected, inp
@@ -0,0 +1,95 @@
1
+ import asyncio
2
+
3
+ from shipeasy import _anon_id
4
+ from shipeasy.middleware import AnonIdMiddleware, AnonIdASGIMiddleware
5
+ from shipeasy._client import _with_anon_id
6
+
7
+
8
+ def _run_wsgi(environ, downstream):
9
+ captured = {}
10
+
11
+ def start_response(status, headers, exc_info=None):
12
+ captured["status"] = status
13
+ captured["headers"] = headers
14
+
15
+ app = AnonIdMiddleware(lambda env, sr: (downstream(env), sr("200 OK", []), [b"ok"])[-1])
16
+ list(app(environ, start_response))
17
+ return captured
18
+
19
+
20
+ def _set_cookie(headers):
21
+ return [v for k, v in headers if k.lower() == "set-cookie"]
22
+
23
+
24
+ def test_wsgi_mints_and_sets_cookie():
25
+ seen = {}
26
+ cap = _run_wsgi(
27
+ {"wsgi.url_scheme": "https"},
28
+ lambda env: seen.update(id=env["shipeasy.anon_id"], cur=_anon_id.current()),
29
+ )
30
+ assert _anon_id.is_valid(seen["id"])
31
+ assert seen["cur"] == seen["id"]
32
+ cookies = _set_cookie(cap["headers"])
33
+ assert len(cookies) == 1
34
+ c = cookies[0]
35
+ assert f"{_anon_id.COOKIE}={seen['id']}" in c
36
+ assert "Path=/" in c and "Max-Age=31536000" in c and "SameSite=Lax" in c and "Secure" in c
37
+ assert "HttpOnly" not in c
38
+ # ContextVar cleared after the request.
39
+ assert _anon_id.current() is None
40
+
41
+
42
+ def test_wsgi_reuses_existing_cookie():
43
+ seen = {}
44
+ cap = _run_wsgi(
45
+ {"HTTP_COOKIE": f"{_anon_id.COOKIE}=stable-1; other=x"},
46
+ lambda env: seen.update(id=env["shipeasy.anon_id"]),
47
+ )
48
+ assert seen["id"] == "stable-1"
49
+ assert _set_cookie(cap["headers"]) == []
50
+
51
+
52
+ def test_wsgi_mints_on_tampered_cookie():
53
+ seen = {}
54
+ _run_wsgi(
55
+ {"HTTP_COOKIE": f"{_anon_id.COOKIE}=bad value!"},
56
+ lambda env: seen.update(id=env["shipeasy.anon_id"]),
57
+ )
58
+ assert seen["id"] != "bad value!"
59
+ assert _anon_id.is_valid(seen["id"])
60
+
61
+
62
+ def test_with_anon_id_defaulting():
63
+ token = _anon_id.set_current("anon-xyz")
64
+ try:
65
+ assert _with_anon_id({})["anonymous_id"] == "anon-xyz"
66
+ assert "anonymous_id" not in _with_anon_id({"user_id": "u9"})
67
+ assert _with_anon_id({"anonymous_id": "caller"})["anonymous_id"] == "caller"
68
+ finally:
69
+ _anon_id.reset_current(token)
70
+ assert _anon_id.current() is None
71
+
72
+
73
+ def test_asgi_mints_and_sets_cookie():
74
+ sent = []
75
+ seen = {}
76
+
77
+ async def downstream(scope, receive, send):
78
+ seen["cur"] = _anon_id.current()
79
+ await send({"type": "http.response.start", "status": 200, "headers": []})
80
+ await send({"type": "http.response.body", "body": b"ok"})
81
+
82
+ async def receive():
83
+ return {"type": "http.request"}
84
+
85
+ async def send(m):
86
+ sent.append(m)
87
+
88
+ app = AnonIdASGIMiddleware(downstream)
89
+ asyncio.run(app({"type": "http", "scheme": "https", "headers": []}, receive, send))
90
+
91
+ assert _anon_id.is_valid(seen["cur"])
92
+ start = next(m for m in sent if m["type"] == "http.response.start")
93
+ cookies = [v.decode() for k, v in start["headers"] if k == b"set-cookie"]
94
+ assert len(cookies) == 1 and "SameSite=Lax" in cookies[0] and "Secure" in cookies[0]
95
+ assert _anon_id.current() is None
@@ -0,0 +1,76 @@
1
+ import hashlib
2
+
3
+ from shipeasy import _telemetry
4
+ from shipeasy._telemetry import Telemetry
5
+
6
+
7
+ def _capture(monkeypatch):
8
+ sent = []
9
+ # Run synchronously and record, instead of spawning daemon threads.
10
+ monkeypatch.setattr(_telemetry, "_send", lambda url: sent.append(url))
11
+ monkeypatch.setattr(
12
+ _telemetry.threading,
13
+ "Thread",
14
+ lambda target, args, daemon: type("T", (), {"start": lambda self: target(*args)})(),
15
+ )
16
+ return sent
17
+
18
+
19
+ def test_emit_path_has_hash_not_raw_key(monkeypatch):
20
+ sent = _capture(monkeypatch)
21
+ t = Telemetry("https://t.example.com/", "sk_secret", side="server", env="prod")
22
+ t.emit("gate", "checkout_v2")
23
+ h = hashlib.sha256(b"sk_secret").hexdigest()
24
+ assert sent == [f"https://t.example.com/t/{h}/server/prod/gate/checkout_v2"]
25
+ assert "sk_secret" not in sent[0]
26
+
27
+
28
+ def test_percent_encodes_resource(monkeypatch):
29
+ sent = _capture(monkeypatch)
30
+ t = Telemetry("https://e.x", "k", side="client", env="prod")
31
+ t.emit("config", "billing/plan name")
32
+ assert sent[0].endswith("/config/billing%2Fplan%20name")
33
+
34
+
35
+ def test_dedup_window_collapses_repeats(monkeypatch):
36
+ sent = _capture(monkeypatch)
37
+ t = Telemetry("https://e.x", "k")
38
+ for _ in range(50):
39
+ t.emit("gate", "g")
40
+ t.emit("gate", "other")
41
+ assert len(sent) == 2
42
+
43
+
44
+ def test_disabled_and_empty_emit_nothing(monkeypatch):
45
+ sent = _capture(monkeypatch)
46
+ Telemetry("https://e.x", "k", disabled=True).emit("gate", "g")
47
+ Telemetry("https://e.x", "").emit("gate", "g")
48
+ Telemetry("", "k").emit("gate", "g")
49
+ assert sent == []
50
+
51
+
52
+ # 1) basic telemetry send works for each entity call, hitting the right URL.
53
+ def test_client_fires_a_beacon_for_each_entity(monkeypatch):
54
+ sent = _capture(monkeypatch)
55
+ from shipeasy import Client
56
+
57
+ c = Client("srv_key", base_url="https://e.x")
58
+ c.get_flag("g", {"user_id": "u"})
59
+ c.get_config("c")
60
+ c.get_experiment("e", {"user_id": "u"}, {})
61
+ assert len(sent) == 3
62
+ assert any(u.endswith("/gate/g") for u in sent)
63
+ assert any(u.endswith("/config/c") for u in sent)
64
+ assert any(u.endswith("/experiment/e") for u in sent)
65
+
66
+
67
+ # 2) telemetry is not sent when disabled in settings.
68
+ def test_client_disable_telemetry_sends_nothing(monkeypatch):
69
+ sent = _capture(monkeypatch)
70
+ from shipeasy import Client
71
+
72
+ c = Client("srv_key", base_url="https://e.x", disable_telemetry=True)
73
+ c.get_flag("g", {"user_id": "u"})
74
+ c.get_config("c")
75
+ c.get_experiment("e", {"user_id": "u"}, {})
76
+ assert sent == []