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.
Files changed (39) hide show
  1. {featureflip-2.3.0 → featureflip-2.3.1}/PKG-INFO +1 -1
  2. {featureflip-2.3.0 → featureflip-2.3.1}/pyproject.toml +1 -1
  3. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_core.py +37 -11
  4. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/client.py +4 -11
  5. {featureflip-2.3.0 → featureflip-2.3.1}/tests/integration/test_api.py +107 -0
  6. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_client.py +26 -13
  7. {featureflip-2.3.0 → featureflip-2.3.1}/.gitignore +0 -0
  8. {featureflip-2.3.0 → featureflip-2.3.1}/CHANGELOG.md +0 -0
  9. {featureflip-2.3.0 → featureflip-2.3.1}/LICENSE +0 -0
  10. {featureflip-2.3.0 → featureflip-2.3.1}/README.md +0 -0
  11. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/__init__.py +0 -0
  12. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_events.py +0 -0
  13. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_http.py +0 -0
  14. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_polling.py +0 -0
  15. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_semver.py +0 -0
  16. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/_streaming.py +0 -0
  17. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/config.py +0 -0
  18. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/context.py +0 -0
  19. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/detail.py +0 -0
  20. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/evaluation.py +0 -0
  21. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/exceptions.py +0 -0
  22. {featureflip-2.3.0 → featureflip-2.3.1}/src/featureflip/models.py +0 -0
  23. {featureflip-2.3.0 → featureflip-2.3.1}/tests/__init__.py +0 -0
  24. {featureflip-2.3.0 → featureflip-2.3.1}/tests/golden/vectors.json +0 -0
  25. {featureflip-2.3.0 → featureflip-2.3.1}/tests/integration/__init__.py +0 -0
  26. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_config.py +0 -0
  27. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_context.py +0 -0
  28. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_core.py +0 -0
  29. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_detail.py +0 -0
  30. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_evaluation.py +0 -0
  31. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_events.py +0 -0
  32. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_exceptions.py +0 -0
  33. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_factory.py +0 -0
  34. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_golden.py +0 -0
  35. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_http.py +0 -0
  36. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_models.py +0 -0
  37. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_polling.py +0 -0
  38. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_semver.py +0 -0
  39. {featureflip-2.3.0 → featureflip-2.3.1}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: featureflip
3
- Version: 2.3.0
3
+ Version: 2.3.1
4
4
  Summary: Python SDK for Featureflip - a feature flag SaaS platform
5
5
  Project-URL: Homepage, https://featureflip.io
6
6
  Project-URL: Documentation, https://featureflip.io/docs/sdks/python/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "featureflip"
7
- version = "2.3.0"
7
+ version = "2.3.1"
8
8
  description = "Python SDK for Featureflip - a feature flag SaaS platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.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
- "context": context or {},
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
- "context": context or {},
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
- reason: EvaluationReason,
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
- "value": value,
417
- "context": context,
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
- reason=detail.reason,
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
- reason: EvaluationReason,
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 backwards compatibility with existing tests that call
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
- reason=reason,
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
- assert event["context"]["user_id"] == "123"
335
- assert event["context"]["email"] == "test@example.com"
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 test_evaluation_event_uses_camelcase_rule_id(self) -> None:
708
- """_queue_evaluation_event must use 'ruleId' not 'rule_id'."""
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
- flag = create_test_flag()
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
- reason=EvaluationReason.RULE_MATCH,
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 "ruleId" in event, f"Event should use 'ruleId' but has keys: {list(event.keys())}"
730
- assert "rule_id" not in event, "Event should not use snake_case 'rule_id'"
731
- assert event["ruleId"] == "rule-abc"
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