featureflip 2.3.0__tar.gz → 2.3.1__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.3.1}/PKG-INFO +1 -1
- {featureflip-2.3.0 → featureflip-2.3.1}/pyproject.toml +1 -1
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_core.py +37 -11
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/client.py +4 -11
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/integration/test_api.py +107 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_client.py +26 -13
- {featureflip-2.3.0 → featureflip-2.3.1}/.gitignore +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/CHANGELOG.md +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/LICENSE +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/README.md +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/__init__.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_events.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_http.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_polling.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_semver.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_streaming.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/config.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/context.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/detail.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/evaluation.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/exceptions.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/models.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/__init__.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/golden/vectors.json +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/integration/__init__.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_config.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_context.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_core.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_detail.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_evaluation.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_events.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_exceptions.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_factory.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_golden.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_http.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_models.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_polling.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_semver.py +0 -0
- {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_streaming.py +0 -0
|
@@ -37,6 +37,19 @@ logger = structlog.get_logger()
|
|
|
37
37
|
T = TypeVar("T")
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def _resolve_user_id(context: dict[str, Any]) -> str | None:
|
|
41
|
+
"""Resolve the built-in user id from a raw context dict.
|
|
42
|
+
|
|
43
|
+
Honors the ``user_id`` / ``userId`` alias (universal across the engine and
|
|
44
|
+
every SDK) so a caller passing either spelling gets the same top-level
|
|
45
|
+
``userId`` on analytics events. Returns ``None`` when neither is present.
|
|
46
|
+
"""
|
|
47
|
+
raw = context.get("user_id")
|
|
48
|
+
if raw is None:
|
|
49
|
+
raw = context.get("userId")
|
|
50
|
+
return str(raw) if raw is not None else None
|
|
51
|
+
|
|
52
|
+
|
|
40
53
|
class _SharedFeatureflipCore:
|
|
41
54
|
"""Internal shared core owning all expensive resources of a FeatureflipClient.
|
|
42
55
|
|
|
@@ -373,10 +386,13 @@ class _SharedFeatureflipCore:
|
|
|
373
386
|
"""Queue a custom event."""
|
|
374
387
|
if self._test_mode_values is not None or self._event_processor is None:
|
|
375
388
|
return
|
|
389
|
+
# Send the backend SdkEventDto shape: top-level userId, no `context`.
|
|
390
|
+
# See apps/evaluation-api Controllers/SdkController.RecordEvents — the
|
|
391
|
+
# backend has no context/value/reason field, so anything else is dropped.
|
|
376
392
|
event = {
|
|
377
393
|
"type": "Custom",
|
|
378
394
|
"flagKey": event_name,
|
|
379
|
-
"
|
|
395
|
+
"userId": _resolve_user_id(context or {}),
|
|
380
396
|
"metadata": metadata or {},
|
|
381
397
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
382
398
|
}
|
|
@@ -386,10 +402,19 @@ class _SharedFeatureflipCore:
|
|
|
386
402
|
"""Send user attributes for segment building."""
|
|
387
403
|
if self._test_mode_values is not None or self._event_processor is None:
|
|
388
404
|
return
|
|
405
|
+
# Promote the user id to the top-level userId field and put the remaining
|
|
406
|
+
# attributes in metadata (stripping both alias spellings so the identity
|
|
407
|
+
# isn't duplicated inside the metadata bag).
|
|
408
|
+
attributes = {
|
|
409
|
+
k: v
|
|
410
|
+
for k, v in (context or {}).items()
|
|
411
|
+
if k not in ("user_id", "userId")
|
|
412
|
+
}
|
|
389
413
|
event = {
|
|
390
414
|
"type": "Identify",
|
|
391
415
|
"flagKey": "$identify",
|
|
392
|
-
"
|
|
416
|
+
"userId": _resolve_user_id(context or {}),
|
|
417
|
+
"metadata": attributes,
|
|
393
418
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
394
419
|
}
|
|
395
420
|
self._event_processor.queue_event(event)
|
|
@@ -402,24 +427,25 @@ class _SharedFeatureflipCore:
|
|
|
402
427
|
def queue_evaluation_event(
|
|
403
428
|
self,
|
|
404
429
|
key: str,
|
|
405
|
-
value: Any,
|
|
406
430
|
context: dict[str, Any],
|
|
407
|
-
|
|
408
|
-
rule_id: str | None = None,
|
|
431
|
+
variation_key: str | None,
|
|
409
432
|
) -> None:
|
|
410
|
-
"""Queue an evaluation event (called by the handle's variation() method).
|
|
433
|
+
"""Queue an evaluation event (called by the handle's variation() method).
|
|
434
|
+
|
|
435
|
+
Emits the backend SdkEventDto shape — top-level ``userId`` and
|
|
436
|
+
``variation`` — mirroring the JS SDK. The backend has no context/value/
|
|
437
|
+
reason/ruleId fields, so they are not sent (System.Text.Json would drop
|
|
438
|
+
them and bind UserId/Variation to null; see #1920).
|
|
439
|
+
"""
|
|
411
440
|
if self._event_processor is None:
|
|
412
441
|
return
|
|
413
442
|
event: dict[str, Any] = {
|
|
414
443
|
"type": "Evaluation",
|
|
415
444
|
"flagKey": key,
|
|
416
|
-
"
|
|
417
|
-
"
|
|
418
|
-
"reason": reason.value,
|
|
445
|
+
"userId": _resolve_user_id(context or {}),
|
|
446
|
+
"variation": variation_key,
|
|
419
447
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
420
448
|
}
|
|
421
|
-
if rule_id is not None:
|
|
422
|
-
event["ruleId"] = rule_id
|
|
423
449
|
self._event_processor.queue_event(event)
|
|
424
450
|
|
|
425
451
|
# --- Shutdown ---
|
|
@@ -103,10 +103,8 @@ class FeatureflipClient:
|
|
|
103
103
|
if track:
|
|
104
104
|
self._core.queue_evaluation_event(
|
|
105
105
|
key=key,
|
|
106
|
-
value=value,
|
|
107
106
|
context=context or {},
|
|
108
|
-
|
|
109
|
-
rule_id=detail.rule_id,
|
|
107
|
+
variation_key=detail.variation_key,
|
|
110
108
|
)
|
|
111
109
|
return cast("T", value)
|
|
112
110
|
except Exception as e:
|
|
@@ -254,20 +252,15 @@ class FeatureflipClient:
|
|
|
254
252
|
def _queue_evaluation_event(
|
|
255
253
|
self,
|
|
256
254
|
key: str,
|
|
257
|
-
value: Any,
|
|
258
255
|
context: dict[str, Any],
|
|
259
|
-
|
|
260
|
-
rule_id: str | None = None,
|
|
256
|
+
variation_key: str | None = None,
|
|
261
257
|
) -> None:
|
|
262
258
|
"""Queue an evaluation event. Delegates to the shared core.
|
|
263
259
|
|
|
264
|
-
Preserved for
|
|
265
|
-
this method directly on the client.
|
|
260
|
+
Preserved for tests that call this method directly on the client.
|
|
266
261
|
"""
|
|
267
262
|
self._core.queue_evaluation_event(
|
|
268
263
|
key=key,
|
|
269
|
-
value=value,
|
|
270
264
|
context=context,
|
|
271
|
-
|
|
272
|
-
rule_id=rule_id,
|
|
265
|
+
variation_key=variation_key,
|
|
273
266
|
)
|
|
@@ -7,6 +7,7 @@ and update mechanisms (polling).
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import time
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
@@ -458,6 +459,112 @@ class TestEventTracking:
|
|
|
458
459
|
client.flush() # Should not raise
|
|
459
460
|
|
|
460
461
|
|
|
462
|
+
class TestEventWireShape:
|
|
463
|
+
"""Events must be sent in the backend SdkEventDto shape (top-level userId /
|
|
464
|
+
variation), matching the JS SDK. The backend has no context/value/reason/ruleId
|
|
465
|
+
fields, so those keys are silently dropped and UserId/Variation bind to null
|
|
466
|
+
unless the SDK populates them client-side. Regression guard for #1920.
|
|
467
|
+
"""
|
|
468
|
+
|
|
469
|
+
@staticmethod
|
|
470
|
+
def _first_event(events_route: respx.Route) -> dict:
|
|
471
|
+
payload = json.loads(events_route.calls[0].request.read())
|
|
472
|
+
assert isinstance(payload, dict)
|
|
473
|
+
assert isinstance(payload["events"], list)
|
|
474
|
+
return payload["events"][0]
|
|
475
|
+
|
|
476
|
+
@respx.mock
|
|
477
|
+
def test_evaluation_event_has_top_level_userid_and_variation(
|
|
478
|
+
self, test_config: Config
|
|
479
|
+
) -> None:
|
|
480
|
+
respx.get("https://eval.featureflip.io/v1/sdk/flags").mock(
|
|
481
|
+
return_value=httpx.Response(200, json=MOCK_FLAGS_RESPONSE)
|
|
482
|
+
)
|
|
483
|
+
events_route = respx.post("https://eval.featureflip.io/v1/sdk/events").mock(
|
|
484
|
+
return_value=httpx.Response(202)
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
with FeatureflipClient(sdk_key="test-sdk-key", config=test_config) as client:
|
|
488
|
+
client.variation("test-flag", {"user_id": "123"}, default=False)
|
|
489
|
+
client.flush()
|
|
490
|
+
|
|
491
|
+
event = self._first_event(events_route)
|
|
492
|
+
assert event["type"] == "Evaluation"
|
|
493
|
+
assert event["flagKey"] == "test-flag"
|
|
494
|
+
assert event["userId"] == "123"
|
|
495
|
+
# served variation of the boolean fallthrough ("on")
|
|
496
|
+
assert event["variation"] == "on"
|
|
497
|
+
# backend DTO has no such fields — must not be sent
|
|
498
|
+
assert "context" not in event
|
|
499
|
+
assert "value" not in event
|
|
500
|
+
assert "reason" not in event
|
|
501
|
+
assert "ruleId" not in event
|
|
502
|
+
|
|
503
|
+
@respx.mock
|
|
504
|
+
def test_evaluation_event_resolves_userid_camelcase_alias(
|
|
505
|
+
self, test_config: Config
|
|
506
|
+
) -> None:
|
|
507
|
+
respx.get("https://eval.featureflip.io/v1/sdk/flags").mock(
|
|
508
|
+
return_value=httpx.Response(200, json=MOCK_FLAGS_RESPONSE)
|
|
509
|
+
)
|
|
510
|
+
events_route = respx.post("https://eval.featureflip.io/v1/sdk/events").mock(
|
|
511
|
+
return_value=httpx.Response(202)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
with FeatureflipClient(sdk_key="test-sdk-key", config=test_config) as client:
|
|
515
|
+
# camelCase alias — a documented-valid keyed context
|
|
516
|
+
client.variation("test-flag", {"userId": "alice"}, default=False)
|
|
517
|
+
client.flush()
|
|
518
|
+
|
|
519
|
+
event = self._first_event(events_route)
|
|
520
|
+
assert event["userId"] == "alice"
|
|
521
|
+
|
|
522
|
+
@respx.mock
|
|
523
|
+
def test_track_event_has_top_level_userid(self, test_config: Config) -> None:
|
|
524
|
+
respx.get("https://eval.featureflip.io/v1/sdk/flags").mock(
|
|
525
|
+
return_value=httpx.Response(200, json=MOCK_FLAGS_RESPONSE)
|
|
526
|
+
)
|
|
527
|
+
events_route = respx.post("https://eval.featureflip.io/v1/sdk/events").mock(
|
|
528
|
+
return_value=httpx.Response(202)
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
with FeatureflipClient(sdk_key="test-sdk-key", config=test_config) as client:
|
|
532
|
+
client.track("signup", {"userId": "alice"}, metadata={"plan": "pro"})
|
|
533
|
+
client.flush()
|
|
534
|
+
|
|
535
|
+
event = self._first_event(events_route)
|
|
536
|
+
assert event["type"] == "Custom"
|
|
537
|
+
assert event["flagKey"] == "signup"
|
|
538
|
+
assert event["userId"] == "alice"
|
|
539
|
+
assert event["metadata"] == {"plan": "pro"}
|
|
540
|
+
assert "context" not in event
|
|
541
|
+
|
|
542
|
+
@respx.mock
|
|
543
|
+
def test_identify_promotes_userid_and_strips_it_from_metadata(
|
|
544
|
+
self, test_config: Config
|
|
545
|
+
) -> None:
|
|
546
|
+
respx.get("https://eval.featureflip.io/v1/sdk/flags").mock(
|
|
547
|
+
return_value=httpx.Response(200, json=MOCK_FLAGS_RESPONSE)
|
|
548
|
+
)
|
|
549
|
+
events_route = respx.post("https://eval.featureflip.io/v1/sdk/events").mock(
|
|
550
|
+
return_value=httpx.Response(202)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
with FeatureflipClient(sdk_key="test-sdk-key", config=test_config) as client:
|
|
554
|
+
client.identify({"userId": "alice", "plan": "pro"})
|
|
555
|
+
client.flush()
|
|
556
|
+
|
|
557
|
+
event = self._first_event(events_route)
|
|
558
|
+
assert event["type"] == "Identify"
|
|
559
|
+
assert event["flagKey"] == "$identify"
|
|
560
|
+
assert event["userId"] == "alice"
|
|
561
|
+
# identity is promoted to userId, not buried in metadata
|
|
562
|
+
assert event["metadata"] == {"plan": "pro"}
|
|
563
|
+
assert "userId" not in event["metadata"]
|
|
564
|
+
assert "user_id" not in event["metadata"]
|
|
565
|
+
assert "context" not in event
|
|
566
|
+
|
|
567
|
+
|
|
461
568
|
class TestPollingUpdates:
|
|
462
569
|
"""Tests for polling-based flag updates."""
|
|
463
570
|
|
|
@@ -331,8 +331,11 @@ class TestIdentify:
|
|
|
331
331
|
client._core._event_processor.queue_event.assert_called_once()
|
|
332
332
|
event = client._core._event_processor.queue_event.call_args[0][0]
|
|
333
333
|
assert event["type"] == "Identify"
|
|
334
|
-
|
|
335
|
-
|
|
334
|
+
# user id is promoted to the top-level backend DTO field; the
|
|
335
|
+
# remaining attributes land in metadata (see #1920)
|
|
336
|
+
assert event["userId"] == "123"
|
|
337
|
+
assert event["metadata"]["email"] == "test@example.com"
|
|
338
|
+
assert "context" not in event
|
|
336
339
|
client.close()
|
|
337
340
|
|
|
338
341
|
def test_identify_event_includes_flagkey(self) -> None:
|
|
@@ -702,13 +705,21 @@ class TestEvaluationEventKeys:
|
|
|
702
705
|
assert "flagKey" in event, f"Event should use 'flagKey' but has keys: {list(event.keys())}"
|
|
703
706
|
assert "flag_key" not in event, "Event should not use snake_case 'flag_key'"
|
|
704
707
|
assert event["flagKey"] == "test-flag"
|
|
708
|
+
# user id is promoted to the top-level backend DTO field
|
|
709
|
+
assert event["userId"] == "123"
|
|
705
710
|
client.close()
|
|
706
711
|
|
|
707
|
-
def
|
|
708
|
-
"""
|
|
712
|
+
def test_evaluation_event_matches_backend_dto_shape(self) -> None:
|
|
713
|
+
"""Evaluation events carry only the backend SdkEventDto fields.
|
|
714
|
+
|
|
715
|
+
The backend maps SdkEventDto -> SdkEvent with type/flagKey/userId/variation/
|
|
716
|
+
timestamp/metadata/prerequisiteKey and nothing else (see
|
|
717
|
+
Evaluation.Api Controllers/SdkController.RecordEvents). Sending context/value/
|
|
718
|
+
reason/ruleId is dead weight silently dropped by System.Text.Json. Regression
|
|
719
|
+
guard for #1920.
|
|
720
|
+
"""
|
|
709
721
|
with patch("featureflip._core.HttpClient") as mock_http:
|
|
710
|
-
|
|
711
|
-
mock_http.return_value.get_flags.return_value = ([flag], [])
|
|
722
|
+
mock_http.return_value.get_flags.return_value = ([create_test_flag()], [])
|
|
712
723
|
|
|
713
724
|
client = FeatureflipClient(
|
|
714
725
|
sdk_key="test-key",
|
|
@@ -716,17 +727,19 @@ class TestEvaluationEventKeys:
|
|
|
716
727
|
)
|
|
717
728
|
client._core._event_processor = MagicMock()
|
|
718
729
|
|
|
719
|
-
# Call _queue_evaluation_event directly with a rule_id
|
|
720
730
|
client._queue_evaluation_event(
|
|
721
731
|
key="test-flag",
|
|
722
|
-
value=True,
|
|
723
732
|
context={"user_id": "123"},
|
|
724
|
-
|
|
725
|
-
rule_id="rule-abc",
|
|
733
|
+
variation_key="on",
|
|
726
734
|
)
|
|
727
735
|
|
|
728
736
|
event = client._core._event_processor.queue_event.call_args[0][0]
|
|
729
|
-
assert "
|
|
730
|
-
assert "
|
|
731
|
-
assert event["
|
|
737
|
+
assert event["type"] == "Evaluation"
|
|
738
|
+
assert event["flagKey"] == "test-flag"
|
|
739
|
+
assert event["userId"] == "123"
|
|
740
|
+
assert event["variation"] == "on"
|
|
741
|
+
assert "timestamp" in event
|
|
742
|
+
# non-DTO fields must not be sent
|
|
743
|
+
for dead in ("context", "value", "reason", "ruleId", "rule_id"):
|
|
744
|
+
assert dead not in event, f"Event should not carry '{dead}'"
|
|
732
745
|
client.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|