featureflip 2.3.0__tar.gz → 2.4.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.
- {featureflip-2.3.0 → featureflip-2.4.0}/CHANGELOG.md +39 -4
- {featureflip-2.3.0 → featureflip-2.4.0}/PKG-INFO +1 -1
- {featureflip-2.3.0 → featureflip-2.4.0}/pyproject.toml +1 -1
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/__init__.py +3 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_core.py +149 -14
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/client.py +34 -14
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/config.py +13 -1
- featureflip-2.4.0/src/featureflip/inspector.py +72 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/integration/test_api.py +107 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_client.py +26 -13
- featureflip-2.4.0/tests/test_inspector.py +578 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/.gitignore +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/LICENSE +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/README.md +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_events.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_http.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_polling.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_semver.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/_streaming.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/context.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/detail.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/evaluation.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/exceptions.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/src/featureflip/models.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/__init__.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/golden/vectors.json +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/integration/__init__.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_config.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_context.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_core.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_detail.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_evaluation.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_events.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_exceptions.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_factory.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_golden.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_http.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_models.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_polling.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_semver.py +0 -0
- {featureflip-2.3.0 → featureflip-2.4.0}/tests/test_streaming.py +0 -0
|
@@ -1,16 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 2.4.0 — 2026-07-29
|
|
4
4
|
|
|
5
5
|
### Added
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- **`onEvaluation` inspector callback.** `inspectors` config option registering in-process observers fired on every evaluation, receiving flag key, context, value, variation key, reason, rule id and prerequisite key. Also threaded through `FeatureflipClient.for_testing(flags, inspectors=...)`, which bypasses `__init__` (#1914).
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- A served variation key the flag does not define now reports reason `ERROR` with the caller's default, instead of a misleading success reason (#1989).
|
|
12
|
+
|
|
13
|
+
## 2.3.1 — 2026-07-14
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Analytics events are sent in the backend's `SdkEventDto` wire shape (#1920).
|
|
18
|
+
|
|
19
|
+
## 2.3.0 — 2026-07-13
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Outage-recovery hardening: polling fallback, reconnect backoff, and SSE `sync` applied as a full store replace (#1868, #1896).
|
|
24
|
+
- The streaming fallback poller is reaped once the stream is healthy, instead of continuing to poll alongside it (#1895).
|
|
25
|
+
|
|
26
|
+
## 2.2.0 — 2026-06-19
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Semantic-version condition operators.** Five new operators — `SemverEquals`, `SemverGreaterThan`, `SemverGreaterThanOrEqual`, `SemverLessThan`, `SemverLessThanOrEqual` — compare an attribute against the condition value(s) as a semantic version (https://semver.org) rather than a decimal, so `2.10.1` correctly satisfies `>= 2.0` (the numeric path mis-parsed multi-segment versions). Tolerant of a leading `v`, `+build` metadata, missing trailing segments (`2.0` == `2.0.0`), and `-prerelease` precedence; unparseable versions match nothing. A rule matches when any supplied condition value satisfies the operator, mirroring the numeric/date operators and the evaluation engine. New module: `featureflip/_semver.py` (#1409).
|
|
10
31
|
|
|
11
32
|
### Fixed
|
|
12
33
|
|
|
13
|
-
- **Multi-word condition operators sent by the server now parse correctly.** The evaluation API serializes operators in PascalCase (e.g. `GreaterThan`, `SemverGreaterThanOrEqual`); operator parsing previously lower-cased the wire string, which collapsed the word boundaries (`GreaterThan` → `greaterthan`) and failed to resolve any multi-word operator. `ConditionOperator` now normalizes PascalCase → snake_case via `_missing_
|
|
34
|
+
- **Multi-word condition operators sent by the server now parse correctly.** The evaluation API serializes operators in PascalCase (e.g. `GreaterThan`, `SemverGreaterThanOrEqual`); operator parsing previously lower-cased the wire string, which collapsed the word boundaries (`GreaterThan` → `greaterthan`) and failed to resolve any multi-word operator. `ConditionOperator` now normalizes PascalCase → snake_case via `_missing_` (#1429).
|
|
35
|
+
- Relational operators match if the attribute satisfies the operator against **any** supplied condition value (#1443).
|
|
36
|
+
- `MatchesRegex` and semver prerelease comparison are now case-sensitive, matching the engine (#1453, #1454).
|
|
37
|
+
- `Before`/`After` date operators aligned with the engine (#1455).
|
|
38
|
+
- Type-aware numeric coercion for `Equals`/`In` (#1458).
|
|
39
|
+
- Keyless rollouts serve the control variation deterministically (#1457).
|
|
40
|
+
- Segment-keyed rules with no segment source fail closed (#1459).
|
|
41
|
+
- Present-but-null attributes are treated as absent (#1460).
|
|
42
|
+
- Environment-level percentage rollouts with no variations no longer throw (#1469).
|
|
43
|
+
|
|
44
|
+
## 2.1.0 — 2026-05-27
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- **Prerequisite flag support.** Flags can declare other flags as prerequisites; the flag's rules and fallthrough only run when every prerequisite serves the expected variation, otherwise the off variation is served with `EvaluationReason.PREREQUISITE_FAILED` and `prerequisite_key` set on the `EvaluationDetail`. Resolution is recursive (depth-capped at 10) with per-call memoisation. New types: `Prerequisite`, `EvaluationReason.PREREQUISITE_FAILED`. New `EvaluationDetail` fields: `variation_key`, `prerequisite_key`. New batch-eval entry point: `FlagEvaluator.evaluate_with_shared_memo(...)` (#1108).
|
|
14
49
|
|
|
15
50
|
## 2.0.0 — 2026-04-08
|
|
16
51
|
|
|
@@ -11,6 +11,7 @@ from featureflip.exceptions import (
|
|
|
11
11
|
FeatureflipError,
|
|
12
12
|
InitializationError,
|
|
13
13
|
)
|
|
14
|
+
from featureflip.inspector import EvaluationEvent, EvaluationInspector
|
|
14
15
|
from featureflip.models import (
|
|
15
16
|
Condition,
|
|
16
17
|
ConditionGroup,
|
|
@@ -36,6 +37,8 @@ __all__: list[str] = [
|
|
|
36
37
|
"ConfigurationError",
|
|
37
38
|
"EvaluationContext",
|
|
38
39
|
"EvaluationDetail",
|
|
40
|
+
"EvaluationEvent",
|
|
41
|
+
"EvaluationInspector",
|
|
39
42
|
"EvaluationReason",
|
|
40
43
|
"FeatureflipClient",
|
|
41
44
|
"FeatureflipError",
|
|
@@ -28,8 +28,10 @@ from featureflip.context import EvaluationContext
|
|
|
28
28
|
from featureflip.detail import EvaluationDetail, EvaluationReason
|
|
29
29
|
from featureflip.evaluation import FlagEvaluator
|
|
30
30
|
from featureflip.exceptions import InitializationError
|
|
31
|
+
from featureflip.inspector import EvaluationEvent
|
|
31
32
|
|
|
32
33
|
if TYPE_CHECKING:
|
|
34
|
+
from featureflip.inspector import EvaluationInspector
|
|
33
35
|
from featureflip.models import FlagConfiguration, Segment
|
|
34
36
|
|
|
35
37
|
logger = structlog.get_logger()
|
|
@@ -37,6 +39,19 @@ logger = structlog.get_logger()
|
|
|
37
39
|
T = TypeVar("T")
|
|
38
40
|
|
|
39
41
|
|
|
42
|
+
def _resolve_user_id(context: dict[str, Any]) -> str | None:
|
|
43
|
+
"""Resolve the built-in user id from a raw context dict.
|
|
44
|
+
|
|
45
|
+
Honors the ``user_id`` / ``userId`` alias (universal across the engine and
|
|
46
|
+
every SDK) so a caller passing either spelling gets the same top-level
|
|
47
|
+
``userId`` on analytics events. Returns ``None`` when neither is present.
|
|
48
|
+
"""
|
|
49
|
+
raw = context.get("user_id")
|
|
50
|
+
if raw is None:
|
|
51
|
+
raw = context.get("userId")
|
|
52
|
+
return str(raw) if raw is not None else None
|
|
53
|
+
|
|
54
|
+
|
|
40
55
|
class _SharedFeatureflipCore:
|
|
41
56
|
"""Internal shared core owning all expensive resources of a FeatureflipClient.
|
|
42
57
|
|
|
@@ -70,6 +85,13 @@ class _SharedFeatureflipCore:
|
|
|
70
85
|
self._sdk_key = sdk_key
|
|
71
86
|
self._config = config
|
|
72
87
|
|
|
88
|
+
# Filter to callables defensively — Python callers bypass the type
|
|
89
|
+
# hints, and a non-callable entry must be dropped at construction
|
|
90
|
+
# rather than blowing up on the evaluation hot path.
|
|
91
|
+
self._inspectors: list[EvaluationInspector] = [
|
|
92
|
+
inspector for inspector in (config.inspectors or []) if callable(inspector)
|
|
93
|
+
]
|
|
94
|
+
|
|
73
95
|
self._test_mode_values = test_mode_values
|
|
74
96
|
self._test_values: dict[str, Any] = dict(test_mode_values) if test_mode_values else {}
|
|
75
97
|
|
|
@@ -127,12 +149,21 @@ class _SharedFeatureflipCore:
|
|
|
127
149
|
return cls(sdk_key=None, config=Config(), test_mode_flags=flags)
|
|
128
150
|
|
|
129
151
|
@classmethod
|
|
130
|
-
def _create_for_testing_stub(
|
|
152
|
+
def _create_for_testing_stub(
|
|
153
|
+
cls,
|
|
154
|
+
flags: dict[str, Any],
|
|
155
|
+
inspectors: list[EvaluationInspector] | None = None,
|
|
156
|
+
) -> _SharedFeatureflipCore:
|
|
131
157
|
"""Create a test-stub core that returns fixed values (no flag evaluation).
|
|
132
158
|
|
|
133
159
|
This is the backing implementation for ``FeatureflipClient.for_testing``.
|
|
160
|
+
``inspectors`` is optional so existing call sites are unaffected.
|
|
134
161
|
"""
|
|
135
|
-
return cls(
|
|
162
|
+
return cls(
|
|
163
|
+
sdk_key=None,
|
|
164
|
+
config=Config(inspectors=list(inspectors) if inspectors else []),
|
|
165
|
+
test_mode_values=flags,
|
|
166
|
+
)
|
|
136
167
|
|
|
137
168
|
# --- Refcount ---
|
|
138
169
|
|
|
@@ -185,7 +216,27 @@ class _SharedFeatureflipCore:
|
|
|
185
216
|
|
|
186
217
|
In test-stub mode, returns the fixed value for the given key (or a
|
|
187
218
|
FLAG_NOT_FOUND detail if the key isn't in the stub map).
|
|
219
|
+
|
|
220
|
+
This is the single evaluation choke point — every ``variation`` /
|
|
221
|
+
``variation_detail`` call funnels through it — so the registered
|
|
222
|
+
evaluation inspectors are notified here, exactly once per call, with
|
|
223
|
+
the value and reason the caller actually receives. ``_evaluate``
|
|
224
|
+
resolves a complete detail on *every* exit path (test-stub hit,
|
|
225
|
+
test-stub miss, flag-not-found, evaluated result, ``None``-value
|
|
226
|
+
default substitution, and the error branch), so notifying once around
|
|
227
|
+
it instruments them all with no risk of a double fire.
|
|
188
228
|
"""
|
|
229
|
+
detail = self._evaluate(key, context, default)
|
|
230
|
+
self._notify_inspectors(key, context, detail)
|
|
231
|
+
return detail
|
|
232
|
+
|
|
233
|
+
def _evaluate(
|
|
234
|
+
self,
|
|
235
|
+
key: str,
|
|
236
|
+
context: dict[str, Any],
|
|
237
|
+
default: T,
|
|
238
|
+
) -> EvaluationDetail:
|
|
239
|
+
"""Resolve the evaluation detail. Never raises — see ``evaluate``."""
|
|
189
240
|
try:
|
|
190
241
|
# Test-stub mode: short-circuit to fixed values
|
|
191
242
|
if self._test_mode_values is not None:
|
|
@@ -208,6 +259,21 @@ class _SharedFeatureflipCore:
|
|
|
208
259
|
flag, eval_context, self._get_segment, all_flags=all_flags
|
|
209
260
|
)
|
|
210
261
|
|
|
262
|
+
# Malformed config: the evaluator selected a variation key the flag
|
|
263
|
+
# does not define (e.g. a fallthrough/rule naming a since-deleted
|
|
264
|
+
# variation). Degrade to the caller's default and report ERROR,
|
|
265
|
+
# mirroring the engine's ServeVariation + the C#/Java SDKs (#1989).
|
|
266
|
+
# A variation that genuinely exists with a None value is NOT this
|
|
267
|
+
# case — hence the key lookup rather than a ``value is None`` check.
|
|
268
|
+
if detail.variation_key and flag.get_variation(detail.variation_key) is None:
|
|
269
|
+
return EvaluationDetail(
|
|
270
|
+
value=default,
|
|
271
|
+
reason=EvaluationReason.ERROR,
|
|
272
|
+
rule_id=detail.rule_id,
|
|
273
|
+
variation_key=detail.variation_key,
|
|
274
|
+
prerequisite_key=detail.prerequisite_key,
|
|
275
|
+
)
|
|
276
|
+
|
|
211
277
|
if detail.value is None:
|
|
212
278
|
return EvaluationDetail(
|
|
213
279
|
value=default,
|
|
@@ -231,6 +297,57 @@ class _SharedFeatureflipCore:
|
|
|
231
297
|
raise RuntimeError("set_test_value() can only be called on a test-stub core")
|
|
232
298
|
self._test_values[key] = value
|
|
233
299
|
|
|
300
|
+
# --- Evaluation inspectors ---
|
|
301
|
+
|
|
302
|
+
def _notify_inspectors(
|
|
303
|
+
self,
|
|
304
|
+
flag_key: str,
|
|
305
|
+
context: dict[str, Any],
|
|
306
|
+
detail: EvaluationDetail,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Fire the registered evaluation inspectors for one evaluation.
|
|
309
|
+
|
|
310
|
+
Never raises. Both halves are isolated:
|
|
311
|
+
|
|
312
|
+
* Building the event must never disturb evaluation — a caller passing a
|
|
313
|
+
non-mapping ``context`` (or mutating its own dict concurrently) would
|
|
314
|
+
otherwise make ``dict(context)`` throw *out of* ``evaluate`` and
|
|
315
|
+
silently downgrade the caller to their default, purely because an
|
|
316
|
+
inspector happened to be registered. On failure the notification is
|
|
317
|
+
skipped and logged.
|
|
318
|
+
* A throwing inspector is isolated: it neither changes the value the
|
|
319
|
+
caller receives nor stops the remaining inspectors from firing; the
|
|
320
|
+
failure is logged and swallowed.
|
|
321
|
+
|
|
322
|
+
The event-build guard lives *here*, not around ``_evaluate`` in
|
|
323
|
+
``evaluate`` — wrapping the call site would route the failure into the
|
|
324
|
+
evaluation error handling and could double-fire the inspectors.
|
|
325
|
+
"""
|
|
326
|
+
# Hot-path guard: with no inspectors registered, allocate nothing.
|
|
327
|
+
if not self._inspectors:
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
try:
|
|
331
|
+
event = EvaluationEvent(
|
|
332
|
+
flag_key=flag_key,
|
|
333
|
+
# Copy so a buggy inspector can't mutate the caller's context.
|
|
334
|
+
context=dict(context or {}),
|
|
335
|
+
value=detail.value,
|
|
336
|
+
reason=detail.reason,
|
|
337
|
+
variation_key=detail.variation_key,
|
|
338
|
+
rule_id=detail.rule_id,
|
|
339
|
+
prerequisite_key=detail.prerequisite_key,
|
|
340
|
+
)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.warning("inspector_event_build_error", key=flag_key, error=str(e))
|
|
343
|
+
return
|
|
344
|
+
|
|
345
|
+
for inspector in self._inspectors:
|
|
346
|
+
try:
|
|
347
|
+
inspector(event)
|
|
348
|
+
except Exception as e:
|
|
349
|
+
logger.warning("inspector_error", key=flag_key, error=str(e))
|
|
350
|
+
|
|
234
351
|
# --- Internal flag store ---
|
|
235
352
|
|
|
236
353
|
def _get_flag(self, key: str) -> FlagConfiguration | None:
|
|
@@ -373,10 +490,13 @@ class _SharedFeatureflipCore:
|
|
|
373
490
|
"""Queue a custom event."""
|
|
374
491
|
if self._test_mode_values is not None or self._event_processor is None:
|
|
375
492
|
return
|
|
493
|
+
# Send the backend SdkEventDto shape: top-level userId, no `context`.
|
|
494
|
+
# See apps/evaluation-api Controllers/SdkController.RecordEvents — the
|
|
495
|
+
# backend has no context/value/reason field, so anything else is dropped.
|
|
376
496
|
event = {
|
|
377
497
|
"type": "Custom",
|
|
378
498
|
"flagKey": event_name,
|
|
379
|
-
"
|
|
499
|
+
"userId": _resolve_user_id(context or {}),
|
|
380
500
|
"metadata": metadata or {},
|
|
381
501
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
382
502
|
}
|
|
@@ -386,10 +506,19 @@ class _SharedFeatureflipCore:
|
|
|
386
506
|
"""Send user attributes for segment building."""
|
|
387
507
|
if self._test_mode_values is not None or self._event_processor is None:
|
|
388
508
|
return
|
|
509
|
+
# Promote the user id to the top-level userId field and put the remaining
|
|
510
|
+
# attributes in metadata (stripping both alias spellings so the identity
|
|
511
|
+
# isn't duplicated inside the metadata bag).
|
|
512
|
+
attributes = {
|
|
513
|
+
k: v
|
|
514
|
+
for k, v in (context or {}).items()
|
|
515
|
+
if k not in ("user_id", "userId")
|
|
516
|
+
}
|
|
389
517
|
event = {
|
|
390
518
|
"type": "Identify",
|
|
391
519
|
"flagKey": "$identify",
|
|
392
|
-
"
|
|
520
|
+
"userId": _resolve_user_id(context or {}),
|
|
521
|
+
"metadata": attributes,
|
|
393
522
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
394
523
|
}
|
|
395
524
|
self._event_processor.queue_event(event)
|
|
@@ -402,24 +531,25 @@ class _SharedFeatureflipCore:
|
|
|
402
531
|
def queue_evaluation_event(
|
|
403
532
|
self,
|
|
404
533
|
key: str,
|
|
405
|
-
value: Any,
|
|
406
534
|
context: dict[str, Any],
|
|
407
|
-
|
|
408
|
-
rule_id: str | None = None,
|
|
535
|
+
variation_key: str | None,
|
|
409
536
|
) -> None:
|
|
410
|
-
"""Queue an evaluation event (called by the handle's variation() method).
|
|
537
|
+
"""Queue an evaluation event (called by the handle's variation() method).
|
|
538
|
+
|
|
539
|
+
Emits the backend SdkEventDto shape — top-level ``userId`` and
|
|
540
|
+
``variation`` — mirroring the JS SDK. The backend has no context/value/
|
|
541
|
+
reason/ruleId fields, so they are not sent (System.Text.Json would drop
|
|
542
|
+
them and bind UserId/Variation to null; see #1920).
|
|
543
|
+
"""
|
|
411
544
|
if self._event_processor is None:
|
|
412
545
|
return
|
|
413
546
|
event: dict[str, Any] = {
|
|
414
547
|
"type": "Evaluation",
|
|
415
548
|
"flagKey": key,
|
|
416
|
-
"
|
|
417
|
-
"
|
|
418
|
-
"reason": reason.value,
|
|
549
|
+
"userId": _resolve_user_id(context or {}),
|
|
550
|
+
"variation": variation_key,
|
|
419
551
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
420
552
|
}
|
|
421
|
-
if rule_id is not None:
|
|
422
|
-
event["ruleId"] = rule_id
|
|
423
553
|
self._event_processor.queue_event(event)
|
|
424
554
|
|
|
425
555
|
# --- Shutdown ---
|
|
@@ -572,7 +702,12 @@ def _reset_for_testing() -> None:
|
|
|
572
702
|
|
|
573
703
|
|
|
574
704
|
def _configs_equal(a: Config, b: Config) -> bool:
|
|
575
|
-
"""Compare two Config objects by value across
|
|
705
|
+
"""Compare two Config objects by value across the 9 comparable fields.
|
|
706
|
+
|
|
707
|
+
``inspectors`` is deliberately excluded: callbacks aren't structurally
|
|
708
|
+
comparable, and a differing callback must not trigger the "different
|
|
709
|
+
options" warning for a shared core.
|
|
710
|
+
"""
|
|
576
711
|
return (
|
|
577
712
|
a.base_url == b.base_url
|
|
578
713
|
and a.connect_timeout == b.connect_timeout
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
from typing import Any, TypeVar, cast
|
|
6
|
+
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
|
7
7
|
|
|
8
8
|
import structlog
|
|
9
9
|
|
|
@@ -11,6 +11,9 @@ from featureflip._core import _get_or_create_core, _SharedFeatureflipCore
|
|
|
11
11
|
from featureflip.config import Config
|
|
12
12
|
from featureflip.detail import EvaluationDetail, EvaluationReason
|
|
13
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from featureflip.inspector import EvaluationInspector
|
|
16
|
+
|
|
14
17
|
logger = structlog.get_logger()
|
|
15
18
|
|
|
16
19
|
T = TypeVar("T")
|
|
@@ -103,10 +106,8 @@ class FeatureflipClient:
|
|
|
103
106
|
if track:
|
|
104
107
|
self._core.queue_evaluation_event(
|
|
105
108
|
key=key,
|
|
106
|
-
value=value,
|
|
107
109
|
context=context or {},
|
|
108
|
-
|
|
109
|
-
rule_id=detail.rule_id,
|
|
110
|
+
variation_key=detail.variation_key,
|
|
110
111
|
)
|
|
111
112
|
return cast("T", value)
|
|
112
113
|
except Exception as e:
|
|
@@ -203,7 +204,11 @@ class FeatureflipClient:
|
|
|
203
204
|
# --- Test-stub factory ---
|
|
204
205
|
|
|
205
206
|
@classmethod
|
|
206
|
-
def for_testing(
|
|
207
|
+
def for_testing(
|
|
208
|
+
cls,
|
|
209
|
+
flags: dict[str, Any],
|
|
210
|
+
inspectors: list[EvaluationInspector] | None = None,
|
|
211
|
+
) -> FeatureflipClient:
|
|
207
212
|
"""Create a test client with fixed flag values.
|
|
208
213
|
|
|
209
214
|
The test client:
|
|
@@ -215,6 +220,11 @@ class FeatureflipClient:
|
|
|
215
220
|
|
|
216
221
|
Args:
|
|
217
222
|
flags: Dictionary mapping flag keys to their values.
|
|
223
|
+
inspectors: Optional evaluation inspectors, so code under test that
|
|
224
|
+
relies on inspector side effects can be unit-tested against a
|
|
225
|
+
stub client. Omitted by default — every existing call site is
|
|
226
|
+
unaffected. Inspectors fire on both stub exit paths (a stub hit
|
|
227
|
+
and a stub miss).
|
|
218
228
|
|
|
219
229
|
Returns:
|
|
220
230
|
A test client instance.
|
|
@@ -226,14 +236,29 @@ class FeatureflipClient:
|
|
|
226
236
|
... })
|
|
227
237
|
>>> client.variation("feature-a", {}, default=False)
|
|
228
238
|
True
|
|
239
|
+
|
|
240
|
+
Example with an inspector:
|
|
241
|
+
>>> seen = []
|
|
242
|
+
>>> client = FeatureflipClient.for_testing(
|
|
243
|
+
... {"feature-a": True}, inspectors=[seen.append]
|
|
244
|
+
... )
|
|
245
|
+
>>> client.variation("feature-a", {}, default=False)
|
|
246
|
+
True
|
|
247
|
+
>>> seen[0].flag_key
|
|
248
|
+
'feature-a'
|
|
229
249
|
"""
|
|
230
250
|
# Create instance without calling __init__ (same technique the original
|
|
231
251
|
# used). Then wire up a standalone test-stub core that never enters
|
|
232
252
|
# _LIVE_CORES (it has no sdk_key).
|
|
253
|
+
#
|
|
254
|
+
# NOTE: this bypasses __init__ entirely, so any change to the client's
|
|
255
|
+
# constructor shape (new instance attribute, new core wiring) must be
|
|
256
|
+
# mirrored here or test clients silently drift from real ones — which
|
|
257
|
+
# is exactly how `inspectors` came to be dropped on this path.
|
|
233
258
|
instance = object.__new__(cls)
|
|
234
259
|
instance._core = _SharedFeatureflipCore(
|
|
235
260
|
sdk_key=None,
|
|
236
|
-
config=Config(),
|
|
261
|
+
config=Config(inspectors=list(inspectors) if inspectors else []),
|
|
237
262
|
test_mode_values=dict(flags),
|
|
238
263
|
)
|
|
239
264
|
instance._closed = False
|
|
@@ -254,20 +279,15 @@ class FeatureflipClient:
|
|
|
254
279
|
def _queue_evaluation_event(
|
|
255
280
|
self,
|
|
256
281
|
key: str,
|
|
257
|
-
value: Any,
|
|
258
282
|
context: dict[str, Any],
|
|
259
|
-
|
|
260
|
-
rule_id: str | None = None,
|
|
283
|
+
variation_key: str | None = None,
|
|
261
284
|
) -> None:
|
|
262
285
|
"""Queue an evaluation event. Delegates to the shared core.
|
|
263
286
|
|
|
264
|
-
Preserved for
|
|
265
|
-
this method directly on the client.
|
|
287
|
+
Preserved for tests that call this method directly on the client.
|
|
266
288
|
"""
|
|
267
289
|
self._core.queue_evaluation_event(
|
|
268
290
|
key=key,
|
|
269
|
-
value=value,
|
|
270
291
|
context=context,
|
|
271
|
-
|
|
272
|
-
rule_id=rule_id,
|
|
292
|
+
variation_key=variation_key,
|
|
273
293
|
)
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from featureflip.inspector import EvaluationInspector
|
|
6
10
|
|
|
7
11
|
|
|
8
12
|
@dataclass
|
|
@@ -19,6 +23,13 @@ class Config:
|
|
|
19
23
|
flush_interval: Interval in seconds for flushing event batches.
|
|
20
24
|
flush_batch_size: Maximum number of events in a batch before flushing.
|
|
21
25
|
init_timeout: Timeout in seconds for client initialization.
|
|
26
|
+
inspectors: Callables invoked synchronously with an ``EvaluationEvent``
|
|
27
|
+
on every flag evaluation. Void observers — the return value is
|
|
28
|
+
ignored, and a raising inspector cannot affect the evaluated value.
|
|
29
|
+
Deliberately excluded from the config-equality comparison (see
|
|
30
|
+
``_core._configs_equal``): callbacks aren't structurally comparable
|
|
31
|
+
and a differing callback must not trigger the "different options"
|
|
32
|
+
warning.
|
|
22
33
|
"""
|
|
23
34
|
|
|
24
35
|
base_url: str = "https://eval.featureflip.io"
|
|
@@ -30,6 +41,7 @@ class Config:
|
|
|
30
41
|
flush_interval: float = 30.0
|
|
31
42
|
flush_batch_size: int = 100
|
|
32
43
|
init_timeout: float = 10.0
|
|
44
|
+
inspectors: list[EvaluationInspector] = field(default_factory=list)
|
|
33
45
|
|
|
34
46
|
def __post_init__(self) -> None:
|
|
35
47
|
"""Validate and normalize configuration values."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Evaluation inspector types for the Featureflip SDK.
|
|
2
|
+
|
|
3
|
+
An *inspector* is a plain callable invoked synchronously, in-process, on every
|
|
4
|
+
flag evaluation. It receives an :class:`EvaluationEvent` describing what the
|
|
5
|
+
caller actually got back and returns nothing — inspectors are void observers,
|
|
6
|
+
meant for piping exposures into analytics / experimentation tooling with no
|
|
7
|
+
backend dependency.
|
|
8
|
+
|
|
9
|
+
Register inspectors at client construction::
|
|
10
|
+
|
|
11
|
+
from featureflip import Config, FeatureflipClient
|
|
12
|
+
|
|
13
|
+
client = FeatureflipClient(
|
|
14
|
+
sdk_key="sdk-xxx",
|
|
15
|
+
config=Config(inspectors=[lambda e: posthog.capture("$feature_flag_called", e)]),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
Inspectors are honored on the *first* construction for a given SDK key, like
|
|
19
|
+
every other config option (the shared core is cached per key).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from featureflip.detail import EvaluationReason
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _utc_now_iso() -> str:
|
|
34
|
+
"""Return the current UTC time as an ISO-8601 string."""
|
|
35
|
+
return datetime.now(timezone.utc).isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class EvaluationEvent:
|
|
40
|
+
"""A single flag evaluation, handed to every registered inspector.
|
|
41
|
+
|
|
42
|
+
This payload is the frozen cross-SDK inspector contract; the field names
|
|
43
|
+
mirror the other Featureflip SDKs (adapted to Python's snake_case).
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
flag_key: The key of the flag that was evaluated.
|
|
47
|
+
context: A copy of the full evaluation context. Mutating it does not
|
|
48
|
+
affect the caller's dictionary. Treat as read-only.
|
|
49
|
+
value: The value the caller actually receives (default already applied).
|
|
50
|
+
reason: Why this value was served. Uses the Python SDK's native
|
|
51
|
+
``EvaluationReason`` enum — each SDK emits its own reason casing.
|
|
52
|
+
variation_key: The winning variation key. ``None`` when the flag wasn't
|
|
53
|
+
found or evaluation errored.
|
|
54
|
+
rule_id: The matched rule's id — set only when the reason is
|
|
55
|
+
``RULE_MATCH``.
|
|
56
|
+
prerequisite_key: The failing prerequisite's flag key — set only when
|
|
57
|
+
the reason is ``PREREQUISITE_FAILED``.
|
|
58
|
+
timestamp: ISO-8601 timestamp of the evaluation.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
flag_key: str
|
|
62
|
+
context: dict[str, Any]
|
|
63
|
+
value: Any
|
|
64
|
+
reason: EvaluationReason
|
|
65
|
+
variation_key: str | None = None
|
|
66
|
+
rule_id: str | None = None
|
|
67
|
+
prerequisite_key: str | None = None
|
|
68
|
+
timestamp: str = field(default_factory=_utc_now_iso)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
EvaluationInspector = Callable[["EvaluationEvent"], None]
|
|
72
|
+
"""A void observer invoked once per evaluation with an :class:`EvaluationEvent`."""
|