flagnetic 0.1.0__py3-none-any.whl

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.
flagnetic/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Python SDK for Flagnetic, self-hosted feature flags and experiments."""
2
+
3
+ from flagnetic._client import Client, Document
4
+ from flagnetic._detail import Detail, Experiment, Stage, Status
5
+ from flagnetic._eval import Context
6
+ from flagnetic._events import Logger
7
+ from flagnetic._overrides import LocalOverrideRefused, LocalOverrides, LocalOverridesNotAllowed
8
+ from flagnetic._reporting import Alias, Exposure, Recorder, SummaryCount
9
+ from flagnetic._version import __version__
10
+
11
+ __all__ = [
12
+ "Alias",
13
+ "Client",
14
+ "Context",
15
+ "Detail",
16
+ "Document",
17
+ "Experiment",
18
+ "Exposure",
19
+ "LocalOverrideRefused",
20
+ "LocalOverrides",
21
+ "LocalOverridesNotAllowed",
22
+ "Logger",
23
+ "Recorder",
24
+ "Stage",
25
+ "Status",
26
+ "SummaryCount",
27
+ "__version__",
28
+ ]
flagnetic/_changes.py ADDED
@@ -0,0 +1,93 @@
1
+ """Which flags a new ruleset may have changed the value of."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Mapping
7
+ from typing import Any
8
+
9
+
10
+ def _items(document: Mapping[str, Any], member: str) -> list[Mapping[str, Any]]:
11
+ value = document.get(member)
12
+ return [item for item in value if isinstance(item, Mapping)] if isinstance(value, list) else []
13
+
14
+
15
+ def _encode(item: Any) -> str:
16
+ return json.dumps(item, sort_keys=True)
17
+
18
+
19
+ def _differing(before: Mapping[str, str], after: Mapping[str, str]) -> set[str]:
20
+ """The keys whose item was added, removed or changed."""
21
+ return {key for key in before.keys() | after.keys() if before.get(key) != after.get(key)}
22
+
23
+
24
+ def _by_key(document: Mapping[str, Any], member: str) -> dict[str, str]:
25
+ return {str(item.get("key")): _encode(item) for item in _items(document, member)}
26
+
27
+
28
+ def changed_flags(before: Mapping[str, Any] | None, after: Mapping[str, Any]) -> list[str]:
29
+ """The sorted keys of the flags whose evaluation may differ between two ruleset documents.
30
+
31
+ Flags added, removed or reconfigured, and flags that reach, through their
32
+ conditions, the conditions of the segments those name, their overrides,
33
+ rollouts and prerequisites, a segment, a declared attribute or unit type, or
34
+ a prerequisite flag that changed, transitively. A flag's own definition
35
+ names its segments and prerequisites but not their contents, so comparing
36
+ definitions alone would miss those.
37
+ """
38
+ old: Mapping[str, Any] = before or {}
39
+ flags = _differing(_by_key(old, "flags"), _by_key(after, "flags"))
40
+ segments = _differing(_by_key(old, "segments"), _by_key(after, "segments"))
41
+ names = _differing(_by_key(old, "attributes"), _by_key(after, "attributes"))
42
+ names |= set(_unit_types(old)) ^ set(_unit_types(after))
43
+
44
+ reaches: dict[str, tuple[set[str], set[str], set[str]]] = {}
45
+ for document in (old, after):
46
+ by_segment = {str(s.get("key")): s for s in _items(document, "segments")}
47
+ for flag in _items(document, "flags"):
48
+ key = str(flag.get("key"))
49
+ reach_segments, reach_names, reach_flags = reaches.setdefault(key, (set(), set(), set()))
50
+
51
+ def walk(
52
+ conditions: Iterable[Mapping[str, Any]],
53
+ reach_segments: set[str] = reach_segments,
54
+ reach_names: set[str] = reach_names,
55
+ reach_flags: set[str] = reach_flags,
56
+ by_segment: Mapping[str, Mapping[str, Any]] = by_segment,
57
+ ) -> None:
58
+ for condition in conditions:
59
+ kind = condition.get("kind")
60
+ if kind in ("in_segment", "not_in_segment"):
61
+ segment = str(condition.get("segment"))
62
+ if segment not in reach_segments:
63
+ reach_segments.add(segment)
64
+ walk(_items(by_segment.get(segment, {}), "conditions"))
65
+ elif kind == "prerequisite":
66
+ reach_flags.add(str(condition.get("flag")))
67
+ else:
68
+ # An attribute, or a unit type read as one.
69
+ reach_names.add(str(condition.get("attribute")))
70
+
71
+ reach_flags.update(str(p.get("flag")) for p in _items(flag, "prerequisites"))
72
+ reach_names.update(str(o.get("unit_type")) for o in _items(flag, "overrides"))
73
+ for rule in _items(flag, "rules"):
74
+ walk(_items(rule, "conditions"))
75
+ serve = rule.get("serve")
76
+ if isinstance(serve, Mapping):
77
+ reach_names.add(str(serve.get("unit_type")))
78
+
79
+ # A flag is affected by what it reaches directly, and by any prerequisite
80
+ # affected in turn; repeat until nothing more is.
81
+ grew = True
82
+ while grew:
83
+ grew = False
84
+ for key, (reach_segments, reach_names, reach_flags) in reaches.items():
85
+ if key not in flags and (reach_segments & segments or reach_names & names or reach_flags & flags):
86
+ flags.add(key)
87
+ grew = True
88
+ return sorted(flags)
89
+
90
+
91
+ def _unit_types(document: Mapping[str, Any]) -> list[str]:
92
+ value = document.get("unit_types")
93
+ return [u or "" for u in value] if isinstance(value, list) else []