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.
- {featureflip-2.4.2 → featureflip-2.4.3}/CHANGELOG.md +9 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/PKG-INFO +3 -3
- {featureflip-2.4.2 → featureflip-2.4.3}/README.md +1 -1
- {featureflip-2.4.2 → featureflip-2.4.3}/pyproject.toml +1 -1
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_core.py +46 -22
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_http.py +78 -4
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/client.py +5 -1
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/evaluation.py +16 -3
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/models.py +12 -5
- featureflip-2.4.3/tests/golden/vectors.json +2058 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_client.py +49 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_evaluation.py +29 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_golden.py +72 -0
- featureflip-2.4.3/tests/test_golden_malformed.py +84 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_http.py +160 -1
- featureflip-2.4.2/tests/golden/vectors.json +0 -1044
- {featureflip-2.4.2 → featureflip-2.4.3}/.gitignore +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/LICENSE +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/__init__.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_events.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_polling.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_semver.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_streaming.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/_version.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/config.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/context.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/detail.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/exceptions.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/src/featureflip/inspector.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/__init__.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/integration/__init__.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/integration/test_api.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_config.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_context.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_core.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_detail.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_events.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_exceptions.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_factory.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_inspector.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_models.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_polling.py +0 -0
- {featureflip-2.4.2 → featureflip-2.4.3}/tests/test_semver.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: featureflip
|
|
3
|
-
Version: 2.4.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
@@ -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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
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, ...],
|