featureflip 2.4.2__tar.gz → 2.4.3__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 (44) hide show
  1. {featureflip-2.4.2 → featureflip-2.4.3}/CHANGELOG.md +9 -0
  2. {featureflip-2.4.2 → featureflip-2.4.3}/PKG-INFO +3 -3
  3. {featureflip-2.4.2 → featureflip-2.4.3}/README.md +1 -1
  4. {featureflip-2.4.2 → featureflip-2.4.3}/pyproject.toml +1 -1
  5. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_core.py +46 -22
  6. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_http.py +78 -4
  7. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/client.py +5 -1
  8. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/evaluation.py +16 -3
  9. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/models.py +12 -5
  10. featureflip-2.4.3/tests/golden/vectors.json +2058 -0
  11. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_client.py +49 -0
  12. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_evaluation.py +29 -0
  13. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_golden.py +72 -0
  14. featureflip-2.4.3/tests/test_golden_malformed.py +84 -0
  15. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_http.py +160 -1
  16. featureflip-2.4.2/tests/golden/vectors.json +0 -1044
  17. {featureflip-2.4.2 → featureflip-2.4.3}/.gitignore +0 -0
  18. {featureflip-2.4.2 → featureflip-2.4.3}/LICENSE +0 -0
  19. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/__init__.py +0 -0
  20. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_events.py +0 -0
  21. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_polling.py +0 -0
  22. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_semver.py +0 -0
  23. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_streaming.py +0 -0
  24. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_version.py +0 -0
  25. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/config.py +0 -0
  26. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/context.py +0 -0
  27. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/detail.py +0 -0
  28. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/exceptions.py +0 -0
  29. {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/inspector.py +0 -0
  30. {featureflip-2.4.2 → featureflip-2.4.3}/tests/__init__.py +0 -0
  31. {featureflip-2.4.2 → featureflip-2.4.3}/tests/integration/__init__.py +0 -0
  32. {featureflip-2.4.2 → featureflip-2.4.3}/tests/integration/test_api.py +0 -0
  33. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_config.py +0 -0
  34. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_context.py +0 -0
  35. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_core.py +0 -0
  36. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_detail.py +0 -0
  37. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_events.py +0 -0
  38. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_exceptions.py +0 -0
  39. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_factory.py +0 -0
  40. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_inspector.py +0 -0
  41. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_models.py +0 -0
  42. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_polling.py +0 -0
  43. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_semver.py +0 -0
  44. {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_streaming.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.3 — 2026-08-23
4
+
5
+ ### Fixed
6
+
7
+ - An unrecognised `FlagType` no longer fails the entire config fetch. One unknown value in one flag discarded the whole payload, so a single new server-side type could blank out every flag the SDK served. ([#2401](https://github.com/canopy-labs/featureflip/issues/2401))
8
+ - An unrecognised condition operator now fails closed instead of matching every user. The default arm returned `false`, which a negated condition then inverted to `true` — so a config naming an operator the SDK did not know could silently target everyone. ([#2262](https://github.com/canopy-labs/featureflip/issues/2262))
9
+ - An unrecognised condition operator is tolerated at deserialization instead of discarding the whole config, matching the string-typed SDKs. ([#2372](https://github.com/canopy-labs/featureflip/issues/2372))
10
+ - `identify()` and `track()` put the same payload on the wire as every other server SDK. The field set and shapes had drifted per language, so the same call produced different events depending on which SDK sent it. ([#2359](https://github.com/canopy-labs/featureflip/issues/2359))
11
+
3
12
  ## 2.4.2 — 2026-08-05
4
13
 
5
14
  ### Fixed
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: featureflip
3
- Version: 2.4.2
3
+ Version: 2.4.3
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/
@@ -122,7 +122,7 @@ print(detail.rule_id) # Rule ID if reason is RULE_MATCH
122
122
  # Track custom events
123
123
  client.track("checkout-completed", {"user_id": "123"}, metadata={"total": 99.99})
124
124
 
125
- # Identify users for segment building
125
+ # Record an identify event for analytics (does not affect flag evaluation)
126
126
  client.identify({"user_id": "123", "email": "user@example.com", "plan": "pro"})
127
127
 
128
128
  # Force flush pending events
@@ -90,7 +90,7 @@ print(detail.rule_id) # Rule ID if reason is RULE_MATCH
90
90
  # Track custom events
91
91
  client.track("checkout-completed", {"user_id": "123"}, metadata={"total": 99.99})
92
92
 
93
- # Identify users for segment building
93
+ # Record an identify event for analytics (does not affect flag evaluation)
94
94
  client.identify({"user_id": "123", "email": "user@example.com", "plan": "pro"})
95
95
 
96
96
  # Force flush pending events
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "featureflip"
7
- version = "2.4.2"
7
+ version = "2.4.3"
8
8
  description = "Python SDK for Featureflip - a feature flag SaaS platform"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -39,6 +39,20 @@ logger = structlog.get_logger()
39
39
  T = TypeVar("T")
40
40
 
41
41
 
42
+ def _compact_event(event: dict[str, Any]) -> dict[str, Any]:
43
+ """Drop the optional wire fields when they carry nothing.
44
+
45
+ ``userId`` and ``metadata`` are both optional on the backend DTO. Every
46
+ server SDK omits them rather than sending ``null`` or an empty object, so
47
+ the same call produces the same bytes whichever SDK made it (#2359).
48
+ """
49
+ if event.get("userId") is None:
50
+ event.pop("userId", None)
51
+ if not event.get("metadata"):
52
+ event.pop("metadata", None)
53
+ return event
54
+
55
+
42
56
  def _resolve_user_id(context: dict[str, Any]) -> str | None:
43
57
  """Resolve the built-in user id from a raw context dict.
44
58
 
@@ -493,17 +507,23 @@ class _SharedFeatureflipCore:
493
507
  # Send the backend SdkEventDto shape: top-level userId, no `context`.
494
508
  # See apps/evaluation-api Controllers/SdkController.RecordEvents — the
495
509
  # backend has no context/value/reason field, so anything else is dropped.
496
- event = {
497
- "type": "Custom",
498
- "flagKey": event_name,
499
- "userId": _resolve_user_id(context or {}),
500
- "metadata": metadata or {},
501
- "timestamp": datetime.now(timezone.utc).isoformat(),
502
- }
510
+ event = _compact_event(
511
+ {
512
+ "type": "Custom",
513
+ "flagKey": event_name,
514
+ "userId": _resolve_user_id(context or {}),
515
+ "metadata": metadata,
516
+ "timestamp": datetime.now(timezone.utc).isoformat(),
517
+ }
518
+ )
503
519
  self._event_processor.queue_event(event)
504
520
 
505
521
  def identify(self, context: dict[str, Any]) -> None:
506
- """Send user attributes for segment building."""
522
+ """Record an identify event for analytics.
523
+
524
+ Does not affect flag evaluation: targeting rules and segments are matched
525
+ against the context passed to each variation call.
526
+ """
507
527
  if self._test_mode_values is not None or self._event_processor is None:
508
528
  return
509
529
  # Promote the user id to the top-level userId field and put the remaining
@@ -514,13 +534,15 @@ class _SharedFeatureflipCore:
514
534
  for k, v in (context or {}).items()
515
535
  if k not in ("user_id", "userId")
516
536
  }
517
- event = {
518
- "type": "Identify",
519
- "flagKey": "$identify",
520
- "userId": _resolve_user_id(context or {}),
521
- "metadata": attributes,
522
- "timestamp": datetime.now(timezone.utc).isoformat(),
523
- }
537
+ event = _compact_event(
538
+ {
539
+ "type": "Identify",
540
+ "flagKey": "$identify",
541
+ "userId": _resolve_user_id(context or {}),
542
+ "metadata": attributes,
543
+ "timestamp": datetime.now(timezone.utc).isoformat(),
544
+ }
545
+ )
524
546
  self._event_processor.queue_event(event)
525
547
 
526
548
  def flush(self) -> None:
@@ -543,13 +565,15 @@ class _SharedFeatureflipCore:
543
565
  """
544
566
  if self._event_processor is None:
545
567
  return
546
- event: dict[str, Any] = {
547
- "type": "Evaluation",
548
- "flagKey": key,
549
- "userId": _resolve_user_id(context or {}),
550
- "variation": variation_key,
551
- "timestamp": datetime.now(timezone.utc).isoformat(),
552
- }
568
+ event: dict[str, Any] = _compact_event(
569
+ {
570
+ "type": "Evaluation",
571
+ "flagKey": key,
572
+ "userId": _resolve_user_id(context or {}),
573
+ "variation": variation_key,
574
+ "timestamp": datetime.now(timezone.utc).isoformat(),
575
+ }
576
+ )
553
577
  self._event_processor.queue_event(event)
554
578
 
555
579
  # --- Shutdown ---
@@ -163,7 +163,7 @@ class HttpClient:
163
163
  return FlagConfiguration(
164
164
  key=data["key"],
165
165
  version=data["version"],
166
- type=FlagType(data["type"].lower()),
166
+ type=self._parse_flag_type(data["type"]),
167
167
  enabled=data["enabled"],
168
168
  variations=[
169
169
  Variation(key=v["key"], value=v["value"])
@@ -228,13 +228,87 @@ class HttpClient:
228
228
  """
229
229
  return Condition(
230
230
  attribute=data["attribute"],
231
- # The wire sends PascalCase operators (e.g. "SemverGreaterThanOrEqual");
232
- # ConditionOperator._missing_ normalizes them to the snake_case members.
233
- operator=ConditionOperator(data["operator"]),
231
+ operator=self._parse_operator(data["operator"]),
234
232
  values=data["values"],
235
233
  negate=data.get("negate", False),
236
234
  )
237
235
 
236
+ @staticmethod
237
+ def _parse_flag_type(raw: Any) -> FlagType | str:
238
+ """Resolve a wire flag type, tolerating one this SDK version does not know.
239
+
240
+ ``FlagType(raw)`` raises ``ValueError`` on an unrecognised name, and because the
241
+ throw surfaced mid-payload it failed the ENTIRE flags parse: the day a new flag
242
+ type ships server-side, every pinned SDK stops serving EVERY flag, not just the
243
+ one carrying the new type (#2395). That is the same total-outage shape #2372
244
+ fixed for ``ConditionOperator``, triggered by an ordinary additive server change.
245
+
246
+ Tolerating it is unconditionally safe here in a way it is NOT for ``ServeType``
247
+ or ``ConditionLogic``: nothing evaluates this field. It is parsed, stored and
248
+ re-exported, and no evaluator reads it — go, ruby and php do not even model it.
249
+ So there is no downstream consumer that could mis-serve on the raw string, and
250
+ equally none that could warn about it, which is why the diagnostic is emitted
251
+ here rather than left to evaluation time.
252
+
253
+ A non-string type is a DIFFERENT axis and is not tolerated, exactly as in
254
+ ``_parse_operator``: an integer is the server and SDK disagreeing about the wire
255
+ format (#2279), and coercing it with ``str()`` would turn ``0`` into the flag
256
+ type ``"0"`` -- indistinguishable from a genuine unknown name, and silently wrong
257
+ in the way #2315 exists to prevent.
258
+ """
259
+ if not isinstance(raw, str):
260
+ raise TypeError(
261
+ f"flag.type must be a string, got {type(raw).__name__}: {raw!r}"
262
+ )
263
+
264
+ try:
265
+ return FlagType(raw.lower())
266
+ except ValueError:
267
+ logger.warning(
268
+ "Unrecognised flag type - retaining the raw value. This SDK version "
269
+ "may be older than the flag configuration.",
270
+ flag_type=raw,
271
+ )
272
+ return raw
273
+
274
+ @staticmethod
275
+ def _parse_operator(raw: Any) -> ConditionOperator | str:
276
+ """Resolve a wire operator, tolerating one this SDK version does not know.
277
+
278
+ The wire sends PascalCase operators (e.g. ``"SemverGreaterThanOrEqual"``);
279
+ ``ConditionOperator._missing_`` normalizes them to the snake_case members.
280
+
281
+ An unrecognised name raises ``ValueError`` from the enum call. That used to
282
+ propagate out of the whole flags-payload parse, so ONE unknown operator in ONE
283
+ condition of ONE flag failed the ENTIRE fetch and every flag fell back to the
284
+ caller's defaults (#2372). The trigger is ordinary: a new operator shipped
285
+ server-side reaching an SDK a customer has pinned.
286
+
287
+ The raw string is returned instead of a member, which is safe because
288
+ ``FlagEvaluator`` fails an unrecognised operator CLOSED (#2262) — its ``case _``
289
+ arm returns None and ``negate`` never inverts it into a match-everyone. Keeping
290
+ the original text (rather than substituting a sentinel) means the evaluator's
291
+ ``unknown_operator`` warning names the operator actually received, which is the
292
+ one detail an operator debugging this needs.
293
+
294
+ A non-string operator is a DIFFERENT axis and is not tolerated. Tolerance here
295
+ is for an unknown *name*; an integer is the server and SDK disagreeing about the
296
+ wire format, and coercing it with ``str()`` would turn ``0`` into the operator
297
+ ``"0"`` -- indistinguishable from a genuine unknown name, and silently wrong in
298
+ exactly the way #2315 exists to prevent. csharp's tolerant converter had the
299
+ identical hole (it resolved numbers by ordinal) and closed it the same way; the
300
+ shared ``malformedConfigVectors`` runner pins both.
301
+ """
302
+ if not isinstance(raw, str):
303
+ raise TypeError(
304
+ f"condition.operator must be a string, got {type(raw).__name__}: {raw!r}"
305
+ )
306
+
307
+ try:
308
+ return ConditionOperator(raw)
309
+ except ValueError:
310
+ return str(raw)
311
+
238
312
  def _parse_serve(self, data: dict[str, Any]) -> ServeConfig:
239
313
  """Parse a serve config from JSON.
240
314
 
@@ -150,7 +150,11 @@ class FeatureflipClient:
150
150
  self._core.track(event_name, context, metadata)
151
151
 
152
152
  def identify(self, context: dict[str, Any]) -> None:
153
- """Send user attributes for segment building."""
153
+ """Record an identify event for analytics.
154
+
155
+ Does not affect flag evaluation: targeting rules and segments are matched
156
+ against the context passed to each variation call.
157
+ """
154
158
  if self._closed:
155
159
  return
156
160
  self._core.identify(context)
@@ -120,6 +120,16 @@ class FlagEvaluator:
120
120
 
121
121
  result = self._evaluate_operator(condition.operator, str_value, values)
122
122
 
123
+ # Issue #2262: an unrecognised operator fails CLOSED. ``not None`` is True
124
+ # in Python, so without this guard a negated unknown operator would match
125
+ # every user and roll the flag out to 100% of traffic. Every
126
+ # ConditionOperator member is covered above and _http.py rejects an unknown
127
+ # operator string at deserialization, so this is defensive here — but it
128
+ # keeps the contract identical to the string-typed SDKs (js/go/ruby/php),
129
+ # where the same input IS reachable over the wire.
130
+ if result is None:
131
+ return False
132
+
123
133
  return not result if condition.negate else result
124
134
 
125
135
  def _evaluate_numeric_equality(self, condition: Condition, attr_value: float) -> bool:
@@ -168,8 +178,8 @@ class FlagEvaluator:
168
178
  return False
169
179
 
170
180
  def _evaluate_operator(
171
- self, operator: ConditionOperator, value: str, targets: list[str]
172
- ) -> bool:
181
+ self, operator: ConditionOperator | str, value: str, targets: list[str]
182
+ ) -> bool | None:
173
183
  """Evaluate an operator against a value and targets.
174
184
 
175
185
  Args:
@@ -233,8 +243,11 @@ class FlagEvaluator:
233
243
  case ConditionOperator.SEMVER_LESS_THAN_OR_EQUAL:
234
244
  return any(compare_semver(value, t, "<=") for t in targets)
235
245
  case _:
246
+ # Unrecognised operator — "cannot evaluate", not "did not match".
247
+ # Returning None (rather than False) keeps the two distinguishable
248
+ # so negate is not applied to an inability (#2262).
236
249
  logger.warning("unknown_operator", operator=operator)
237
- return False
250
+ return None
238
251
 
239
252
  def _compare_numeric(self, value: str, target: str, op: str) -> bool:
240
253
  """Compare two values as numbers.
@@ -125,13 +125,16 @@ class Condition:
125
125
 
126
126
  Attributes:
127
127
  attribute: The context attribute to evaluate (e.g., "country", "user_id").
128
- operator: The comparison operator to use.
128
+ operator: The comparison operator to use. Typed to also admit a raw ``str``:
129
+ an operator name this SDK version does not recognise is retained
130
+ verbatim rather than failing the whole config parse (#2372), and the
131
+ evaluator fails it closed (#2262).
129
132
  values: The values to compare against.
130
133
  negate: If True, the condition result is inverted.
131
134
  """
132
135
 
133
136
  attribute: str
134
- operator: ConditionOperator
137
+ operator: ConditionOperator | str
135
138
  values: list[Any]
136
139
  negate: bool = False
137
140
 
@@ -296,7 +299,11 @@ class FlagConfiguration:
296
299
  Attributes:
297
300
  key: Unique identifier for the flag.
298
301
  version: Version number of the configuration.
299
- type: The type of value this flag returns.
302
+ type: The type of value this flag returns. Typed to also admit a raw
303
+ ``str``: a flag type this SDK version does not recognise is retained
304
+ verbatim rather than failing the whole config parse (#2395). Nothing
305
+ evaluates this field, so an unrecognised value has no effect beyond
306
+ the diagnostic emitted at parse time.
300
307
  enabled: Whether the flag is enabled for evaluation.
301
308
  variations: All possible variations for this flag.
302
309
  rules: Targeting rules evaluated in priority order.
@@ -308,7 +315,7 @@ class FlagConfiguration:
308
315
 
309
316
  key: str
310
317
  version: int
311
- type: FlagType
318
+ type: FlagType | str
312
319
  enabled: bool
313
320
  variations: tuple[Variation, ...]
314
321
  rules: tuple[TargetingRule, ...]
@@ -323,7 +330,7 @@ class FlagConfiguration:
323
330
  self,
324
331
  key: str,
325
332
  version: int,
326
- type: FlagType,
333
+ type: FlagType | str,
327
334
  enabled: bool,
328
335
  variations: list[Variation] | tuple[Variation, ...],
329
336
  rules: list[TargetingRule] | tuple[TargetingRule, ...],