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.
- redpennon-1.0.0/.gitignore +15 -0
- redpennon-1.0.0/PKG-INFO +91 -0
- redpennon-1.0.0/README.md +71 -0
- redpennon-1.0.0/pyproject.toml +46 -0
- redpennon-1.0.0/src/redpennon/__init__.py +30 -0
- redpennon-1.0.0/src/redpennon/client.py +298 -0
- redpennon-1.0.0/tests/test_client.py +442 -0
redpennon-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/redpennon/)
|
|
24
|
+
[](https://pypi.org/project/redpennon/)
|
|
25
|
+
[](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
|
+
[](https://pypi.org/project/redpennon/)
|
|
4
|
+
[](https://pypi.org/project/redpennon/)
|
|
5
|
+
[](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"
|