redpennon 1.0.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,15 @@
1
+ .DS_Store
2
+ *.pyc
3
+ __pycache__/
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .python-version
8
+ .venv/
9
+ venv/
10
+ node_modules/
11
+ dist/
12
+ coverage/
13
+ *.egg-info/
14
+ .coverage
15
+ htmlcov/
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: redpennon
3
+ Version: 1.0.0
4
+ Summary: RedPennon feature flag evaluation API client
5
+ Project-URL: Homepage, https://redpennon.dev
6
+ Project-URL: Documentation, https://docs.redpennon.dev
7
+ Project-URL: Repository, https://github.com/redpennon/sdks
8
+ Project-URL: Bug Tracker, https://github.com/redpennon/sdks/issues
9
+ License-Expression: MIT
10
+ Keywords: feature-flags,feature-toggles,redpennon
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: httpx<1,>=0.27
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov<6,>=5; extra == 'dev'
18
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # redpennon · Python SDK
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/redpennon)](https://pypi.org/project/redpennon/)
24
+ [![Python](https://img.shields.io/pypi/pyversions/redpennon)](https://pypi.org/project/redpennon/)
25
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/redpennon/sdks/blob/main/LICENSE)
26
+
27
+ Thin HTTP client for the [RedPennon](https://redpennon.dev) feature flag evaluation API.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install redpennon
33
+ ```
34
+
35
+ Requires Python 3.12+ and [`httpx`](https://www.python-httpx.org/).
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from redpennon import Client, UserContext
41
+
42
+ client = Client(api_key="YOUR_ENV_API_KEY")
43
+
44
+ # Simple: get the resolved value or your default on any failure.
45
+ enabled = client.variable_value("dark-mode-enabled", default=False)
46
+
47
+ # With user context (for targeting rules).
48
+ user = UserContext(id="user_123", email="alice@example.com")
49
+ enabled = client.variable_value("dark-mode-enabled", default=False, user=user)
50
+
51
+ # Full result: value + variation + reason.
52
+ result = client.variable("dark-mode-enabled", user=user)
53
+ print(result.value, result.variation, result.reason)
54
+
55
+ # Batch: resolve many flags in one round-trip.
56
+ results = client.variables(["flag-a", "flag-b"], user=user)
57
+ flag_a = results["flag-a"].value
58
+ ```
59
+
60
+ ## API
61
+
62
+ ### `Client(api_key, *, base_url=None, http_client=None)`
63
+
64
+ | Parameter | Type | Description |
65
+ |-----------|------|-------------|
66
+ | `api_key` | `str` | Environment API key from the RedPennon dashboard. |
67
+ | `base_url` | `str \| None` | Override the API base URL (default: `https://api.redpennon.dev`). |
68
+ | `http_client` | `httpx.Client \| None` | Custom client — useful for testing with `MockTransport`. |
69
+
70
+ ### `client.variable_value(key, *, default, user=None) → T`
71
+
72
+ Returns the resolved value or `default` on any failure (network error, non-2xx, `value=null`). Use this in production code — your default is the load-bearing contract.
73
+
74
+ ### `client.variable(key, *, user=None) → VariableResult`
75
+
76
+ Returns the full evaluation result (`value`, `variation`, `reason`, `feature`). Raises `APIError` on network or non-2xx errors.
77
+
78
+ ### `client.variables(keys, *, user=None) → dict[str, VariableResult]`
79
+
80
+ Batch evaluation — one HTTP round-trip for multiple flags. Unknown keys return `reason="variable_not_found"` inline rather than raising.
81
+
82
+ ## Local development
83
+
84
+ ```bash
85
+ pip install -e '.[dev]'
86
+ pytest
87
+ ```
88
+
89
+ ## License
90
+
91
+ [MIT](../LICENSE) © RedPennon
@@ -0,0 +1,71 @@
1
+ # redpennon · Python SDK
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/redpennon)](https://pypi.org/project/redpennon/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/redpennon)](https://pypi.org/project/redpennon/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/redpennon/sdks/blob/main/LICENSE)
6
+
7
+ Thin HTTP client for the [RedPennon](https://redpennon.dev) feature flag evaluation API.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install redpennon
13
+ ```
14
+
15
+ Requires Python 3.12+ and [`httpx`](https://www.python-httpx.org/).
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from redpennon import Client, UserContext
21
+
22
+ client = Client(api_key="YOUR_ENV_API_KEY")
23
+
24
+ # Simple: get the resolved value or your default on any failure.
25
+ enabled = client.variable_value("dark-mode-enabled", default=False)
26
+
27
+ # With user context (for targeting rules).
28
+ user = UserContext(id="user_123", email="alice@example.com")
29
+ enabled = client.variable_value("dark-mode-enabled", default=False, user=user)
30
+
31
+ # Full result: value + variation + reason.
32
+ result = client.variable("dark-mode-enabled", user=user)
33
+ print(result.value, result.variation, result.reason)
34
+
35
+ # Batch: resolve many flags in one round-trip.
36
+ results = client.variables(["flag-a", "flag-b"], user=user)
37
+ flag_a = results["flag-a"].value
38
+ ```
39
+
40
+ ## API
41
+
42
+ ### `Client(api_key, *, base_url=None, http_client=None)`
43
+
44
+ | Parameter | Type | Description |
45
+ |-----------|------|-------------|
46
+ | `api_key` | `str` | Environment API key from the RedPennon dashboard. |
47
+ | `base_url` | `str \| None` | Override the API base URL (default: `https://api.redpennon.dev`). |
48
+ | `http_client` | `httpx.Client \| None` | Custom client — useful for testing with `MockTransport`. |
49
+
50
+ ### `client.variable_value(key, *, default, user=None) → T`
51
+
52
+ Returns the resolved value or `default` on any failure (network error, non-2xx, `value=null`). Use this in production code — your default is the load-bearing contract.
53
+
54
+ ### `client.variable(key, *, user=None) → VariableResult`
55
+
56
+ Returns the full evaluation result (`value`, `variation`, `reason`, `feature`). Raises `APIError` on network or non-2xx errors.
57
+
58
+ ### `client.variables(keys, *, user=None) → dict[str, VariableResult]`
59
+
60
+ Batch evaluation — one HTTP round-trip for multiple flags. Unknown keys return `reason="variable_not_found"` inline rather than raising.
61
+
62
+ ## Local development
63
+
64
+ ```bash
65
+ pip install -e '.[dev]'
66
+ pytest
67
+ ```
68
+
69
+ ## License
70
+
71
+ [MIT](../LICENSE) © RedPennon
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "redpennon"
7
+ version = "1.0.0"
8
+ description = "RedPennon feature flag evaluation API client"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ keywords = ["feature-flags", "feature-toggles", "redpennon"]
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ ]
18
+ dependencies = [
19
+ "httpx>=0.27,<1",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://redpennon.dev"
24
+ Documentation = "https://docs.redpennon.dev"
25
+ Repository = "https://github.com/redpennon/sdks"
26
+ "Bug Tracker" = "https://github.com/redpennon/sdks/issues"
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8,<9",
31
+ "pytest-cov>=5,<6",
32
+ ]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/redpennon"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ addopts = "-q --cov=redpennon --cov-report=term-missing --cov-fail-under=80"
40
+
41
+ [tool.coverage.run]
42
+ branch = true
43
+ source = ["redpennon"]
44
+
45
+ [tool.coverage.report]
46
+ omit = ["*/tests/*"]
@@ -0,0 +1,30 @@
1
+ """RedPennon API client."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
4
+
5
+ from redpennon.client import (
6
+ APIError,
7
+ Client,
8
+ DEFAULT_API_BASE_URL,
9
+ EvaluationReason,
10
+ EventPayload,
11
+ TrackEventsResult,
12
+ UserContext,
13
+ VariableResult,
14
+ )
15
+
16
+ __all__ = [
17
+ "APIError",
18
+ "Client",
19
+ "DEFAULT_API_BASE_URL",
20
+ "EvaluationReason",
21
+ "EventPayload",
22
+ "TrackEventsResult",
23
+ "UserContext",
24
+ "VariableResult",
25
+ ]
26
+
27
+ try:
28
+ __version__ = _pkg_version("redpennon")
29
+ except PackageNotFoundError: # pragma: no cover - source checkouts
30
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,298 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Any, Iterable, Literal, Mapping, Sequence, TypeVar
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+
10
+ DEFAULT_API_BASE_URL = "https://api.redpennon.dev"
11
+
12
+ _LOG = logging.getLogger("redpennon")
13
+
14
+ T = TypeVar("T")
15
+
16
+ EvaluationReason = Literal[
17
+ "targeting_rule_matched",
18
+ "default_variation",
19
+ "no_rule_matched",
20
+ "targeting_disabled",
21
+ "feature_complete",
22
+ "feature_deleted",
23
+ "feature_archived",
24
+ "self_targeting_override",
25
+ "variable_not_found",
26
+ ]
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class UserContext:
31
+ """Targeting context the SDK forwards to the server.
32
+
33
+ ``app_version``, ``platform``, and ``country`` are built-ins that
34
+ most flag platforms auto-populate from a client-side SDK runtime.
35
+ Server-side SDKs can't reliably detect any of them — populate them
36
+ manually from your request context (e.g. parsed from a User-Agent
37
+ header or a CDN-supplied geo header) if you want to target on
38
+ them.
39
+
40
+ ``custom_data`` carries arbitrary attributes for targeting
41
+ conditions of type ``custom_property``; keys are looked up by the
42
+ rule's ``custom_key`` and values may be scalars or list-of-strings.
43
+ On the wire it serialises as ``custom_data``.
44
+ """
45
+
46
+ id: str | None = None
47
+ email: str | None = None
48
+ organisation_id: str | None = None
49
+ ip: str | None = None
50
+ audiences: Sequence[str] | None = None
51
+ app_version: str | None = None
52
+ platform: str | None = None
53
+ country: str | None = None
54
+ custom_data: Mapping[str, Any] | None = None
55
+
56
+ def to_payload(self) -> dict[str, Any]:
57
+ out: dict[str, Any] = {}
58
+ if self.id is not None:
59
+ out["id"] = self.id
60
+ if self.email is not None:
61
+ out["email"] = self.email
62
+ if self.organisation_id is not None:
63
+ out["organisation_id"] = self.organisation_id
64
+ if self.ip is not None:
65
+ out["ip"] = self.ip
66
+ if self.audiences is not None:
67
+ out["audiences"] = list(self.audiences)
68
+ if self.app_version is not None:
69
+ out["app_version"] = self.app_version
70
+ if self.platform is not None:
71
+ out["platform"] = self.platform
72
+ if self.country is not None:
73
+ out["country"] = self.country
74
+ if self.custom_data is not None:
75
+ out["custom_data"] = dict(self.custom_data)
76
+ return out
77
+
78
+
79
+ class APIError(Exception):
80
+ """Raised by ``Client.variable`` / ``Client.variables`` on non-2xx
81
+ responses or transport errors. ``Client.variable_value`` swallows
82
+ these and substitutes the caller's default.
83
+
84
+ ``code`` mirrors the platform's governance error vocabulary
85
+ (``rate_limit_exceeded``, ``organisation_suspended``,
86
+ ``monthly_active_users_exceeded`` …) when the response body carries
87
+ a structured ``{"error", "code"}`` payload. ``None`` for transport
88
+ errors or unstructured responses; callers branching on governance
89
+ state should match on ``code``, not ``message``.
90
+ """
91
+
92
+ def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
93
+ self.status_code = status_code
94
+ self.message = message
95
+ self.code = code
96
+ super().__init__(f"redpennon: api error {status_code}: {message}")
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class VariableResult:
101
+ """Server-side evaluation outcome for a single variable.
102
+
103
+ ``value`` is ``None`` whenever the platform served no value
104
+ (unknown key, targeting disabled, feature deleted/archived) —
105
+ callers using :meth:`Client.variable` directly should treat the
106
+ null case the same way :meth:`Client.variable_value` does (fall
107
+ back to a default). ``variation`` and ``feature`` are likewise
108
+ ``None`` in those cases.
109
+ """
110
+
111
+ key: str
112
+ value: Any
113
+ variation: str | None
114
+ reason: EvaluationReason
115
+ feature: str | None
116
+ evaluation_trace: str | None = None
117
+
118
+ @classmethod
119
+ def from_payload(cls, payload: Mapping[str, Any]) -> "VariableResult":
120
+ return cls(
121
+ key=payload["key"],
122
+ value=payload.get("value"),
123
+ variation=payload.get("variation"),
124
+ reason=payload.get("reason"), # type: ignore[arg-type]
125
+ feature=payload.get("feature"),
126
+ evaluation_trace=payload.get("evaluation_trace"),
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class EventPayload:
132
+ event: str
133
+ variable: str
134
+ variation: str
135
+ user: UserContext | None = None
136
+ value: float | None = None
137
+ occurred_at: str | None = None
138
+ evaluation_trace: str | None = None
139
+
140
+
141
+ @dataclass(frozen=True, slots=True)
142
+ class TrackEventsResult:
143
+ accepted: int
144
+
145
+
146
+ class Client:
147
+ """HTTP client for the RedPennon variable-key evaluation API.
148
+
149
+ Two evaluation entry points cover the two ergonomics teams reach
150
+ for:
151
+
152
+ * :meth:`variable_value` — "give me the value or this fallback".
153
+ Swallows network and API errors and returns the caller's
154
+ default. This is the load-bearing contract: the developer's
155
+ default *always* wins on any failure, so the calling app
156
+ cannot crash because RedPennon is unreachable.
157
+
158
+ * :meth:`variable` — "give me the full result object" with
159
+ ``variation``, ``reason``, ``feature``, etc. Surfaces errors
160
+ as :class:`APIError`; opting in to the result shape opts in
161
+ to error handling.
162
+
163
+ Batch evaluation via :meth:`variables` lets one HTTP round-trip
164
+ resolve many flags against the same user context.
165
+ """
166
+
167
+ def __init__(
168
+ self,
169
+ *,
170
+ api_key: str,
171
+ base_url: str = DEFAULT_API_BASE_URL,
172
+ timeout: float = 30.0,
173
+ client: httpx.Client | None = None,
174
+ ) -> None:
175
+ self._api_key = api_key
176
+ self._origin = base_url.rstrip("/")
177
+ self._owns_client = client is None
178
+ self._client = client or httpx.Client(timeout=timeout)
179
+
180
+ @property
181
+ def api_key(self) -> str:
182
+ return self._api_key
183
+
184
+ @property
185
+ def base_url(self) -> str:
186
+ return self._origin
187
+
188
+ def close(self) -> None:
189
+ if self._owns_client:
190
+ self._client.close()
191
+
192
+ def __enter__(self) -> "Client":
193
+ return self
194
+
195
+ def __exit__(self, *args: object) -> None:
196
+ self.close()
197
+
198
+ # ------------------------------------------------------------------
199
+ # Evaluation
200
+
201
+ def variable(
202
+ self,
203
+ key: str,
204
+ *,
205
+ user: UserContext | None = None,
206
+ ) -> VariableResult:
207
+ """Resolve one variable to its full :class:`VariableResult`."""
208
+ body: dict[str, Any] = {}
209
+ if user is not None:
210
+ body["user"] = user.to_payload()
211
+ response = self._post(
212
+ f"/v1/variables/{quote(key, safe='')}", body,
213
+ )
214
+ return VariableResult.from_payload(response.json())
215
+
216
+ def variable_value(
217
+ self,
218
+ key: str,
219
+ *,
220
+ default: T,
221
+ user: UserContext | None = None,
222
+ ) -> T:
223
+ """Resolve a variable and return its value, or ``default`` on
224
+ any failure (unreachable API, non-2xx, ``value=None``)."""
225
+ try:
226
+ result = self.variable(key, user=user)
227
+ except (APIError, httpx.HTTPError) as exc:
228
+ _LOG.warning("redpennon: variable_value(%r) failed: %s", key, exc)
229
+ return default
230
+ return default if result.value is None else result.value # type: ignore[return-value]
231
+
232
+ def variables(
233
+ self,
234
+ keys: Iterable[str],
235
+ *,
236
+ user: UserContext | None = None,
237
+ ) -> dict[str, VariableResult]:
238
+ """Resolve a batch of variables in one HTTP round-trip."""
239
+ body: dict[str, Any] = {"keys": list(keys)}
240
+ if user is not None:
241
+ body["user"] = user.to_payload()
242
+ response = self._post("/v1/variables", body)
243
+ results = response.json().get("results", {})
244
+ return {k: VariableResult.from_payload(v) for k, v in results.items()}
245
+
246
+ def track_events(self, events: list[EventPayload]) -> TrackEventsResult:
247
+ """Record a batch of events against variable evaluations.
248
+
249
+ Raises :class:`APIError` on any non-202 response.
250
+ """
251
+ serialised = []
252
+ for ev in events:
253
+ item: dict[str, Any] = {
254
+ "event": ev.event,
255
+ "variable": ev.variable,
256
+ "variation": ev.variation,
257
+ }
258
+ if ev.user is not None:
259
+ item["user"] = ev.user.to_payload()
260
+ if ev.value is not None:
261
+ item["value"] = ev.value
262
+ if ev.occurred_at is not None:
263
+ item["occurred_at"] = ev.occurred_at
264
+ if ev.evaluation_trace is not None:
265
+ item["evaluation_trace"] = ev.evaluation_trace
266
+ serialised.append(item)
267
+ response = self._post("/v1/events", {"events": serialised})
268
+ return TrackEventsResult(accepted=response.json()["accepted"])
269
+
270
+ # ------------------------------------------------------------------
271
+ # Internal HTTP
272
+
273
+ def _post(self, path: str, body: Mapping[str, Any]) -> httpx.Response:
274
+ response = self._client.post(
275
+ f"{self._origin}{path}",
276
+ headers={
277
+ "X-API-Key": self._api_key,
278
+ "Content-Type": "application/json",
279
+ },
280
+ json=body,
281
+ )
282
+ if response.status_code // 100 != 2:
283
+ message, code = self._error_fields(response)
284
+ raise APIError(response.status_code, message, code)
285
+ return response
286
+
287
+ @staticmethod
288
+ def _error_fields(response: httpx.Response) -> tuple[str, str | None]:
289
+ try:
290
+ payload = response.json()
291
+ except ValueError:
292
+ return (response.text or response.reason_phrase, None)
293
+ if not isinstance(payload, dict):
294
+ return (response.reason_phrase, None)
295
+ message = str(payload["error"]) if "error" in payload else response.reason_phrase
296
+ raw_code = payload.get("code")
297
+ code = str(raw_code) if isinstance(raw_code, str) and raw_code else None
298
+ return message, code
@@ -0,0 +1,442 @@
1
+ """Tests for the RedPennon Python client.
2
+
3
+ We drive the client through an ``httpx.MockTransport`` so every test
4
+ asserts on the exact HTTP request the SDK sends (URL, headers, body)
5
+ and on the exact response the SDK surfaces. This is the only way to
6
+ pin the SDK ↔ API contract without spinning up the full Django service
7
+ under test.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Any
13
+
14
+ import httpx
15
+ import pytest
16
+
17
+ from redpennon import APIError, Client, DEFAULT_API_BASE_URL, UserContext
18
+ from redpennon.client import EventPayload, TrackEventsResult, VariableResult
19
+
20
+
21
+ def _mock_transport(handler):
22
+ """Wrap a request handler in an ``httpx.MockTransport`` that
23
+ captures every outbound request and a list the test can assert on."""
24
+ captured: list[httpx.Request] = []
25
+
26
+ def _wrapped(request: httpx.Request) -> httpx.Response:
27
+ captured.append(request)
28
+ return handler(request)
29
+
30
+ return httpx.MockTransport(_wrapped), captured
31
+
32
+
33
+ def _client_with(handler) -> tuple[Client, list[httpx.Request]]:
34
+ transport, captured = _mock_transport(handler)
35
+ http = httpx.Client(transport=transport)
36
+ return Client(api_key="env-key", client=http), captured
37
+
38
+
39
+ # ----------------------------------------------------------------------
40
+ # Construction + plumbing
41
+ #
42
+ # Construction (api key, injected httpx.Client, default base URL) is
43
+ # exercised transitively by every per-method test below via
44
+ # ``_client_with``; redundant smoke tests were removed to keep this file
45
+ # focused on contract behaviour.
46
+
47
+ def test_api_error_exposes_status_code_and_message() -> None:
48
+ err = APIError(401, "Invalid or missing API key.")
49
+ assert err.status_code == 401
50
+ assert err.message == "Invalid or missing API key."
51
+ assert err.code is None
52
+ assert "401" in str(err)
53
+
54
+
55
+ def test_api_error_parses_governance_code_from_response_body() -> None:
56
+ """Governance error responses carry ``{"error", "code"}``; callers
57
+ branch on ``code`` (``rate_limit_exceeded``, ``organisation_suspended``
58
+ …) rather than parsing ``message``. The code propagates through
59
+ ``Client._post`` whenever the body is structured JSON."""
60
+ import httpx
61
+
62
+ def handler(req: httpx.Request) -> httpx.Response:
63
+ return httpx.Response(
64
+ 429,
65
+ json={"error": "Rate limit exceeded.", "code": "rate_limit_exceeded"},
66
+ )
67
+
68
+ client, _ = _client_with(handler)
69
+ with client, pytest.raises(APIError) as exc:
70
+ client.variable("any-key")
71
+
72
+ assert exc.value.status_code == 429
73
+ assert exc.value.code == "rate_limit_exceeded"
74
+
75
+
76
+ def test_user_context_omits_unset_optional_fields() -> None:
77
+ assert UserContext(id="u1").to_payload() == {"id": "u1"}
78
+
79
+
80
+ def test_user_context_serialises_builtins_and_custom_data() -> None:
81
+ payload = UserContext(
82
+ id="u1",
83
+ email="a@b.com",
84
+ organisation_id="o1",
85
+ ip="1.2.3.4",
86
+ audiences=["beta"],
87
+ app_version="4.12.0",
88
+ platform="ios",
89
+ country="AU",
90
+ custom_data={"plan": "enterprise", "trial_days": 7},
91
+ ).to_payload()
92
+ assert payload == {
93
+ "id": "u1",
94
+ "email": "a@b.com",
95
+ "organisation_id": "o1",
96
+ "ip": "1.2.3.4",
97
+ "audiences": ["beta"],
98
+ "app_version": "4.12.0",
99
+ "platform": "ios",
100
+ "country": "AU",
101
+ "custom_data": {"plan": "enterprise", "trial_days": 7},
102
+ }
103
+
104
+
105
+ # ----------------------------------------------------------------------
106
+ # ``variable()`` — single-key, full result
107
+
108
+ class TestVariable:
109
+ def test_returns_full_result_shape(self) -> None:
110
+ def handler(req: httpx.Request) -> httpx.Response:
111
+ return httpx.Response(200, json={
112
+ "key": "show-banner",
113
+ "value": True,
114
+ "variation": "on",
115
+ "reason": "targeting_rule_matched",
116
+ "feature": "marketing",
117
+ })
118
+
119
+ client, captured = _client_with(handler)
120
+ with client:
121
+ result = client.variable("show-banner", user=UserContext(id="u"))
122
+
123
+ assert isinstance(result, VariableResult)
124
+ assert result.key == "show-banner"
125
+ assert result.value is True
126
+ assert result.variation == "on"
127
+ assert result.reason == "targeting_rule_matched"
128
+ assert result.feature == "marketing"
129
+
130
+ assert len(captured) == 1
131
+ req = captured[0]
132
+ assert str(req.url) == f"{DEFAULT_API_BASE_URL}/v1/variables/show-banner"
133
+ assert req.method == "POST"
134
+ assert req.headers["X-API-Key"] == "env-key"
135
+ assert req.headers["Content-Type"] == "application/json"
136
+ assert json.loads(req.content) == {"user": {"id": "u"}}
137
+
138
+ def test_url_encodes_the_key(self) -> None:
139
+ captured_urls: list[str] = []
140
+
141
+ def handler(req: httpx.Request) -> httpx.Response:
142
+ captured_urls.append(str(req.url))
143
+ return httpx.Response(200, json={
144
+ "key": "weird/key", "value": None, "variation": None,
145
+ "reason": "variable_not_found", "feature": None,
146
+ })
147
+
148
+ client, _ = _client_with(handler)
149
+ with client:
150
+ client.variable("weird/key")
151
+
152
+ assert captured_urls[0].endswith("/v1/variables/weird%2Fkey")
153
+
154
+ def test_omits_user_when_no_context_supplied(self) -> None:
155
+ captured_bodies: list[bytes] = []
156
+
157
+ def handler(req: httpx.Request) -> httpx.Response:
158
+ captured_bodies.append(req.content)
159
+ return httpx.Response(200, json={
160
+ "key": "k", "value": False, "variation": "off",
161
+ "reason": "default_variation", "feature": "f",
162
+ })
163
+
164
+ client, _ = _client_with(handler)
165
+ with client:
166
+ client.variable("k")
167
+
168
+ assert json.loads(captured_bodies[0]) == {}
169
+
170
+ def test_non_2xx_raises_api_error(self) -> None:
171
+ def handler(req: httpx.Request) -> httpx.Response:
172
+ return httpx.Response(401, json={"error": "Invalid or missing API key."})
173
+
174
+ client, _ = _client_with(handler)
175
+ with client, pytest.raises(APIError) as exc:
176
+ client.variable("k")
177
+
178
+ assert exc.value.status_code == 401
179
+
180
+ def test_populates_evaluation_trace_when_present(self) -> None:
181
+ """evaluation_trace is an opaque signed string, not a dict."""
182
+ trace = "rpe_v1:sometoken"
183
+
184
+ def handler(req: httpx.Request) -> httpx.Response:
185
+ return httpx.Response(200, json={
186
+ "key": "show-banner",
187
+ "value": True,
188
+ "variation": "on",
189
+ "reason": "targeting_rule_matched",
190
+ "feature": "marketing",
191
+ "evaluation_trace": trace,
192
+ })
193
+
194
+ client, _ = _client_with(handler)
195
+ with client:
196
+ result = client.variable("show-banner")
197
+
198
+ assert isinstance(result.evaluation_trace, str)
199
+ assert result.evaluation_trace == "rpe_v1:sometoken"
200
+
201
+ def test_evaluation_trace_is_none_when_absent(self) -> None:
202
+ def handler(req: httpx.Request) -> httpx.Response:
203
+ return httpx.Response(200, json={
204
+ "key": "k", "value": False, "variation": "off",
205
+ "reason": "default_variation", "feature": "f",
206
+ })
207
+
208
+ client, _ = _client_with(handler)
209
+ with client:
210
+ result = client.variable("k")
211
+
212
+ assert result.evaluation_trace is None
213
+
214
+
215
+ # ----------------------------------------------------------------------
216
+ # ``variable_value()`` — fail-open fallback to caller's default
217
+
218
+ class TestVariableValue:
219
+ def test_returns_server_value(self) -> None:
220
+ def handler(req: httpx.Request) -> httpx.Response:
221
+ return httpx.Response(200, json={
222
+ "key": "discount-pct", "value": 25, "variation": "on",
223
+ "reason": "targeting_rule_matched", "feature": "promo",
224
+ })
225
+
226
+ client, _ = _client_with(handler)
227
+ with client:
228
+ value = client.variable_value("discount-pct", default=0, user=UserContext(id="u"))
229
+ assert value == 25
230
+
231
+ def test_falls_back_when_value_is_null(self) -> None:
232
+ """``variable_not_found``, deleted/archived features, and
233
+ targeting-disabled all surface as ``value=None``; the SDK
234
+ substitutes the caller's default."""
235
+ def handler(req: httpx.Request) -> httpx.Response:
236
+ return httpx.Response(200, json={
237
+ "key": "discount-pct", "value": None, "variation": None,
238
+ "reason": "variable_not_found", "feature": None,
239
+ })
240
+
241
+ client, _ = _client_with(handler)
242
+ with client:
243
+ value = client.variable_value("discount-pct", default=10)
244
+ assert value == 10
245
+
246
+ def test_swallows_network_errors_and_returns_default(self) -> None:
247
+ """An unreachable API must not crash the caller. The
248
+ result-shape ``variable()`` method still raises — callers that
249
+ want errors can opt in to it."""
250
+ def handler(req: httpx.Request) -> httpx.Response:
251
+ raise httpx.ConnectError("offline")
252
+
253
+ client, _ = _client_with(handler)
254
+ with client:
255
+ value = client.variable_value("flag", default="fallback")
256
+ assert value == "fallback"
257
+
258
+ def test_swallows_api_errors_and_returns_default(self) -> None:
259
+ def handler(req: httpx.Request) -> httpx.Response:
260
+ return httpx.Response(401, json={"error": "nope"})
261
+
262
+ client, _ = _client_with(handler)
263
+ with client:
264
+ value = client.variable_value("flag", default=False)
265
+ assert value is False
266
+
267
+
268
+ # ----------------------------------------------------------------------
269
+ # ``variables()`` — batch
270
+
271
+ class TestVariablesBatch:
272
+ def test_returns_result_per_key(self) -> None:
273
+ def handler(req: httpx.Request) -> httpx.Response:
274
+ return httpx.Response(200, json={
275
+ "results": {
276
+ "a": {
277
+ "key": "a", "value": True, "variation": "on",
278
+ "reason": "default_variation", "feature": "f-a",
279
+ },
280
+ "b": {
281
+ "key": "b", "value": None, "variation": None,
282
+ "reason": "variable_not_found", "feature": None,
283
+ },
284
+ },
285
+ })
286
+
287
+ client, captured = _client_with(handler)
288
+ with client:
289
+ results = client.variables(["a", "b"], user=UserContext(id="u"))
290
+
291
+ assert results["a"].value is True
292
+ assert results["b"].reason == "variable_not_found"
293
+ assert str(captured[0].url) == f"{DEFAULT_API_BASE_URL}/v1/variables"
294
+ assert json.loads(captured[0].content) == {
295
+ "keys": ["a", "b"],
296
+ "user": {"id": "u"},
297
+ }
298
+
299
+ def test_omits_user_when_no_context_supplied(self) -> None:
300
+ captured_bodies: list[bytes] = []
301
+
302
+ def handler(req: httpx.Request) -> httpx.Response:
303
+ captured_bodies.append(req.content)
304
+ return httpx.Response(200, json={"results": {}})
305
+
306
+ client, _ = _client_with(handler)
307
+ with client:
308
+ client.variables([])
309
+
310
+ assert json.loads(captured_bodies[0]) == {"keys": []}
311
+
312
+ def test_non_2xx_raises_api_error(self) -> None:
313
+ def handler(req: httpx.Request) -> httpx.Response:
314
+ return httpx.Response(401, json={"error": "nope"})
315
+
316
+ client, _ = _client_with(handler)
317
+ with client, pytest.raises(APIError) as exc:
318
+ client.variables(["x"])
319
+
320
+ assert exc.value.status_code == 401
321
+
322
+
323
+ # ----------------------------------------------------------------------
324
+ # ``track_events()`` — POST /v1/events
325
+
326
+ class TestTrackEvents:
327
+ def test_returns_accepted_count(self) -> None:
328
+ def handler(req: httpx.Request) -> httpx.Response:
329
+ return httpx.Response(202, json={"accepted": 2})
330
+
331
+ client, captured = _client_with(handler)
332
+ events = [
333
+ EventPayload(event="button_clicked", variable="checkout-flow", variation="variant-a"),
334
+ EventPayload(event="page_viewed", variable="new-nav", variation="on"),
335
+ ]
336
+ with client:
337
+ result = client.track_events(events)
338
+
339
+ assert isinstance(result, TrackEventsResult)
340
+ assert result.accepted == 2
341
+
342
+ req = captured[0]
343
+ assert str(req.url) == f"{DEFAULT_API_BASE_URL}/v1/events"
344
+ assert req.method == "POST"
345
+ assert req.headers["X-API-Key"] == "env-key"
346
+ assert req.headers["Content-Type"] == "application/json"
347
+ body = json.loads(req.content)
348
+ assert body == {
349
+ "events": [
350
+ {"event": "button_clicked", "variable": "checkout-flow", "variation": "variant-a"},
351
+ {"event": "page_viewed", "variable": "new-nav", "variation": "on"},
352
+ ]
353
+ }
354
+
355
+ def test_serialises_all_optional_fields(self) -> None:
356
+ def handler(req: httpx.Request) -> httpx.Response:
357
+ return httpx.Response(202, json={"accepted": 1})
358
+
359
+ client, captured = _client_with(handler)
360
+ event = EventPayload(
361
+ event="purchase",
362
+ variable="checkout-flow",
363
+ variation="variant-a",
364
+ user=UserContext(id="user-123"),
365
+ value=42.5,
366
+ occurred_at="2026-05-01T05:06:07Z",
367
+ evaluation_trace="rpe_v1:abc123",
368
+ )
369
+ with client:
370
+ client.track_events([event])
371
+
372
+ body = json.loads(captured[0].content)
373
+ assert body["events"][0] == {
374
+ "event": "purchase",
375
+ "variable": "checkout-flow",
376
+ "variation": "variant-a",
377
+ "user": {"id": "user-123"},
378
+ "value": 42.5,
379
+ "occurred_at": "2026-05-01T05:06:07Z",
380
+ "evaluation_trace": "rpe_v1:abc123",
381
+ }
382
+
383
+ def test_omits_none_optional_fields(self) -> None:
384
+ def handler(req: httpx.Request) -> httpx.Response:
385
+ return httpx.Response(202, json={"accepted": 1})
386
+
387
+ client, captured = _client_with(handler)
388
+ with client:
389
+ client.track_events([
390
+ EventPayload(event="e", variable="v", variation="var")
391
+ ])
392
+
393
+ body = json.loads(captured[0].content)
394
+ event_body = body["events"][0]
395
+ assert "user" not in event_body
396
+ assert "value" not in event_body
397
+ assert "occurred_at" not in event_body
398
+ assert "evaluation_trace" not in event_body
399
+
400
+ def test_non_202_raises_api_error(self) -> None:
401
+ def handler(req: httpx.Request) -> httpx.Response:
402
+ return httpx.Response(401, json={"error": "Invalid or missing API key."})
403
+
404
+ client, _ = _client_with(handler)
405
+ with client, pytest.raises(APIError) as exc:
406
+ client.track_events([
407
+ EventPayload(event="e", variable="v", variation="var")
408
+ ])
409
+
410
+ assert exc.value.status_code == 401
411
+
412
+ def test_raises_api_error_with_governance_code(self) -> None:
413
+ def handler(req: httpx.Request) -> httpx.Response:
414
+ return httpx.Response(
415
+ 429,
416
+ json={"error": "Rate limit exceeded.", "code": "rate_limit_exceeded"},
417
+ )
418
+
419
+ client, _ = _client_with(handler)
420
+ with client, pytest.raises(APIError) as exc:
421
+ client.track_events([
422
+ EventPayload(event="e", variable="v", variation="var")
423
+ ])
424
+
425
+ assert exc.value.status_code == 429
426
+ assert exc.value.code == "rate_limit_exceeded"
427
+
428
+ def test_raises_api_error_on_events_batch_too_large(self) -> None:
429
+ def handler(req: httpx.Request) -> httpx.Response:
430
+ return httpx.Response(
431
+ 413,
432
+ json={"error": "Batch too large.", "code": "events_batch_too_large"},
433
+ )
434
+
435
+ client, _ = _client_with(handler)
436
+ with client, pytest.raises(APIError) as exc:
437
+ client.track_events([
438
+ EventPayload(event="e", variable="v", variation="var")
439
+ ])
440
+
441
+ assert exc.value.status_code == 413
442
+ assert exc.value.code == "events_batch_too_large"