configdirector-server-sdk 0.2.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.
Files changed (44) hide show
  1. configdirector/__init__.py +83 -0
  2. configdirector/_bundle.py +176 -0
  3. configdirector/_evaluation/__init__.py +47 -0
  4. configdirector/_evaluation/_json_pointer.py +40 -0
  5. configdirector/_evaluation/_json_value.py +28 -0
  6. configdirector/_evaluation/_rapidhash.py +115 -0
  7. configdirector/_evaluation/array_comparison.py +25 -0
  8. configdirector/_evaluation/condition_evaluator.py +94 -0
  9. configdirector/_evaluation/config_evaluator.py +123 -0
  10. configdirector/_evaluation/date_comparison.py +77 -0
  11. configdirector/_evaluation/numeric_comparison.py +61 -0
  12. configdirector/_evaluation/percent_hashing.py +12 -0
  13. configdirector/_evaluation/semver_comparison.py +63 -0
  14. configdirector/_evaluation/text_comparison.py +40 -0
  15. configdirector/_evaluation/types.py +134 -0
  16. configdirector/_eventsource/__init__.py +27 -0
  17. configdirector/_eventsource/client.py +350 -0
  18. configdirector/_eventsource/errors.py +31 -0
  19. configdirector/_eventsource/parser.py +175 -0
  20. configdirector/_eventsource/transport.py +162 -0
  21. configdirector/_eventsource/types.py +58 -0
  22. configdirector/_http.py +86 -0
  23. configdirector/_logger.py +12 -0
  24. configdirector/_telemetry/__init__.py +49 -0
  25. configdirector/_telemetry/collector.py +188 -0
  26. configdirector/_telemetry/compact_json.py +40 -0
  27. configdirector/_telemetry/events.py +156 -0
  28. configdirector/_telemetry/queue.py +126 -0
  29. configdirector/_telemetry/reporter.py +157 -0
  30. configdirector/_telemetry/value_id.py +30 -0
  31. configdirector/_transport/__init__.py +23 -0
  32. configdirector/_transport/base.py +84 -0
  33. configdirector/_transport/polling.py +159 -0
  34. configdirector/_transport/streaming.py +110 -0
  35. configdirector/_value_parser.py +94 -0
  36. configdirector/_version.py +3 -0
  37. configdirector/client.py +640 -0
  38. configdirector/errors.py +45 -0
  39. configdirector/py.typed +0 -0
  40. configdirector/types.py +552 -0
  41. configdirector_server_sdk-0.2.0.dist-info/METADATA +49 -0
  42. configdirector_server_sdk-0.2.0.dist-info/RECORD +44 -0
  43. configdirector_server_sdk-0.2.0.dist-info/WHEEL +4 -0
  44. configdirector_server_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,83 @@
1
+ """ConfigDirector Python server SDK.
2
+
3
+ ConfigDirector is a remote configuration and feature flag service.
4
+
5
+ Example::
6
+
7
+ from configdirector import Context, Metadata, create_client
8
+
9
+ client = create_client(
10
+ "YOUR-SERVER-SDK-KEY",
11
+ metadata=Metadata(app_name="my-awesome-app", app_version="1.0.0"),
12
+ )
13
+ client.initialize()
14
+
15
+ if client.get_value("new-checkout", False, Context(id="user-123")):
16
+ ...
17
+ """
18
+
19
+ from ._version import __version__
20
+ from .client import create_client
21
+ from .errors import (
22
+ ConfigDirectorConnectionError,
23
+ ConfigDirectorError,
24
+ ConfigDirectorInitializationError,
25
+ ConfigDirectorTypeError,
26
+ ConfigDirectorValidationError,
27
+ )
28
+ from .types import (
29
+ ClientEvent,
30
+ ClientHooks,
31
+ ClientReadyEvent,
32
+ ClientReadyHandler,
33
+ ConfigDirectorClient,
34
+ ConfigDirectorLogger,
35
+ ConfigEvaluatedEvent,
36
+ ConfigEvaluatedHandler,
37
+ ConfigEvaluation,
38
+ ConfigState,
39
+ ConfigsUpdatedEvent,
40
+ ConfigsUpdatedHandler,
41
+ ConfigType,
42
+ ConfigValue,
43
+ ConnectionMode,
44
+ ConnectionOptions,
45
+ Context,
46
+ EvaluationReason,
47
+ Metadata,
48
+ Subscription,
49
+ TelemetryOptions,
50
+ WatchHandler,
51
+ )
52
+
53
+ __all__ = [
54
+ "ClientEvent",
55
+ "ClientHooks",
56
+ "ClientReadyEvent",
57
+ "ClientReadyHandler",
58
+ "ConfigDirectorClient",
59
+ "ConfigDirectorConnectionError",
60
+ "ConfigDirectorError",
61
+ "ConfigDirectorInitializationError",
62
+ "ConfigDirectorLogger",
63
+ "ConfigDirectorTypeError",
64
+ "ConfigDirectorValidationError",
65
+ "ConfigEvaluatedEvent",
66
+ "ConfigEvaluatedHandler",
67
+ "ConfigEvaluation",
68
+ "ConfigState",
69
+ "ConfigType",
70
+ "ConfigValue",
71
+ "ConfigsUpdatedEvent",
72
+ "ConfigsUpdatedHandler",
73
+ "ConnectionMode",
74
+ "ConnectionOptions",
75
+ "Context",
76
+ "EvaluationReason",
77
+ "Metadata",
78
+ "Subscription",
79
+ "TelemetryOptions",
80
+ "WatchHandler",
81
+ "__version__",
82
+ "create_client",
83
+ ]
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Literal, cast
6
+
7
+ from ._evaluation._json_value import to_json_string
8
+ from ._evaluation.types import (
9
+ Condition,
10
+ ConditionalRule,
11
+ Config,
12
+ EnumTypeConstraints,
13
+ NumericTypeConstraints,
14
+ Percentage,
15
+ PercentageRule,
16
+ Rule,
17
+ RuleValue,
18
+ TargetingRules,
19
+ Variation,
20
+ )
21
+ from .types import ConfigDirectorLogger, ConfigType
22
+
23
+ __all__ = ["BundleKind", "ConfigBundle", "parse_bundle"]
24
+
25
+ BundleKind = Literal["full", "delta"]
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ConfigBundle:
30
+ configs: dict[str, Config] = field(default_factory=dict)
31
+ kind: BundleKind = "full"
32
+ environment_id: str | None = None
33
+ project_id: str | None = None
34
+ # Echoed back on the next poll so the server can answer with a delta. The server may omit
35
+ # it, in which case every poll returns a full bundle.
36
+ timestamp: str | None = None
37
+
38
+
39
+ def parse_bundle(payload: str, logger: ConfigDirectorLogger) -> ConfigBundle:
40
+ document = json.loads(payload)
41
+ if not isinstance(document, dict):
42
+ raise ValueError(f"Expected the config bundle to be a JSON object, got {type(document).__name__}")
43
+
44
+ return ConfigBundle(
45
+ configs=_parse_configs(document.get("configs"), logger),
46
+ kind="delta" if document.get("kind") == "delta" else "full",
47
+ environment_id=_optional_string(document.get("environmentId")),
48
+ project_id=_optional_string(document.get("projectId")),
49
+ timestamp=_optional_string(document.get("timestamp")),
50
+ )
51
+
52
+
53
+ def _parse_configs(raw: Any, logger: ConfigDirectorLogger) -> dict[str, Config]:
54
+ if not isinstance(raw, dict):
55
+ return {}
56
+
57
+ configs: dict[str, Config] = {}
58
+ for key, definition in raw.items():
59
+ try:
60
+ configs[str(key)] = _parse_config(definition)
61
+ except (AttributeError, KeyError, TypeError, ValueError) as error:
62
+ # One unreadable config must not cost the application every other config in the
63
+ # bundle. It keeps whatever definition it already had, or falls back to defaults.
64
+ logger.warning("Skipping the config %r, its definition could not be read: %r", key, error)
65
+ return configs
66
+
67
+
68
+ def _parse_config(raw: Any) -> Config:
69
+ target = raw["target"]
70
+ return Config(
71
+ id=str(raw["id"]),
72
+ key=str(raw["key"]),
73
+ type=cast(ConfigType, raw["type"]),
74
+ target=TargetingRules(
75
+ default_value=_as_string(target.get("defaultValue")),
76
+ rules=[_parse_rule(rule) for rule in target.get("rules") or []],
77
+ default_value_id=_optional_string(target.get("defaultValueId")),
78
+ ),
79
+ variations=[_parse_variation(variation) for variation in raw.get("variations") or []],
80
+ type_constraints=_parse_type_constraints(raw.get("typeConstraints")),
81
+ )
82
+
83
+
84
+ def _parse_rule(raw: Any) -> Rule:
85
+ kind = raw.get("type")
86
+ percentages = [_parse_percentage(percentage) for percentage in raw.get("percentages") or []]
87
+
88
+ if kind == "percentage":
89
+ return PercentageRule(
90
+ id=str(raw["id"]),
91
+ order=_optional_int(raw.get("order")),
92
+ percentages=percentages,
93
+ )
94
+
95
+ # Anything that is not a percentage rule is carried as a conditional rule with its wire type
96
+ # intact. A rule kind this SDK version predates is then skipped during evaluation rather than
97
+ # discarded here, which keeps the reason for the skip visible in one place.
98
+ return ConditionalRule(
99
+ id=str(raw["id"]),
100
+ order=_optional_int(raw.get("order")),
101
+ value=_rule_value(raw.get("value")),
102
+ conditions=[_parse_condition(condition) for condition in raw.get("conditions") or []],
103
+ percentages=percentages,
104
+ target=str(raw.get("target") or "value"),
105
+ type=str(kind) if kind is not None else "conditional",
106
+ value_id=_optional_string(raw.get("valueId")),
107
+ )
108
+
109
+
110
+ def _parse_condition(raw: Any) -> Condition:
111
+ return Condition(
112
+ id=str(raw["id"]),
113
+ attribute=str(raw["attribute"]),
114
+ operator=str(raw["operator"]),
115
+ target_type=str(raw["targetType"]),
116
+ target_values=[_as_string(value) for value in raw.get("targetValues") or []],
117
+ trait=_optional_string(raw.get("trait")),
118
+ )
119
+
120
+
121
+ def _parse_percentage(raw: Any) -> Percentage:
122
+ return Percentage(
123
+ id=str(raw["id"]),
124
+ percentage=float(raw["percentage"]),
125
+ value=_rule_value(raw.get("value")),
126
+ value_id=_optional_string(raw.get("valueId")),
127
+ )
128
+
129
+
130
+ def _parse_variation(raw: Any) -> Variation:
131
+ value = raw.get("value")
132
+ return Variation(
133
+ value=value if isinstance(value, (str, int, float, bool)) else _as_string(value),
134
+ name=_optional_string(raw.get("name")),
135
+ )
136
+
137
+
138
+ def _parse_type_constraints(raw: Any) -> NumericTypeConstraints | EnumTypeConstraints | None:
139
+ if not isinstance(raw, dict):
140
+ return None
141
+ if "valueType" in raw:
142
+ return EnumTypeConstraints(
143
+ value_type="number" if raw.get("valueType") == "number" else "string",
144
+ values=[_as_string(value) for value in raw.get("values") or []],
145
+ )
146
+ return NumericTypeConstraints(min=raw.get("min"), max=raw.get("max"))
147
+
148
+
149
+ def _rule_value(value: Any) -> RuleValue:
150
+ if value is None or isinstance(value, (str, int, float, bool)):
151
+ return value
152
+ # A structured value reaches the application as the JSON text it was sent as.
153
+ return json.dumps(value, separators=(",", ":"))
154
+
155
+
156
+ def _as_string(value: Any) -> str:
157
+ if isinstance(value, str):
158
+ return value
159
+ if value is None:
160
+ return ""
161
+ if isinstance(value, (int, float, bool)):
162
+ # Rendered the way every other ConfigDirector SDK renders it, so that a rule written
163
+ # against `true` or `26` compares the same everywhere.
164
+ return to_json_string(value)
165
+ return json.dumps(value, separators=(",", ":"))
166
+
167
+
168
+ def _optional_string(value: Any) -> str | None:
169
+ return value if isinstance(value, str) else None
170
+
171
+
172
+ def _optional_int(value: Any) -> int | None:
173
+ # Rules without a usable order evaluate last, in the order the server sent them.
174
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
175
+ return None
176
+ return int(value)
@@ -0,0 +1,47 @@
1
+ from .array_comparison import compare_array
2
+ from .condition_evaluator import evaluate_condition
3
+ from .config_evaluator import ConfigEvaluator
4
+ from .date_comparison import compare_date
5
+ from .numeric_comparison import compare_numeric
6
+ from .percent_hashing import assign_percentage
7
+ from .semver_comparison import compare_semver
8
+ from .text_comparison import compare_text
9
+ from .types import (
10
+ Condition,
11
+ ConditionalRule,
12
+ Config,
13
+ EnumTypeConstraints,
14
+ EvaluationContext,
15
+ NumericTypeConstraints,
16
+ Percentage,
17
+ PercentageRule,
18
+ Rule,
19
+ Target,
20
+ TargetingRules,
21
+ TargetType,
22
+ Variation,
23
+ )
24
+
25
+ __all__ = [
26
+ "Condition",
27
+ "ConditionalRule",
28
+ "Config",
29
+ "ConfigEvaluator",
30
+ "EnumTypeConstraints",
31
+ "EvaluationContext",
32
+ "NumericTypeConstraints",
33
+ "Percentage",
34
+ "PercentageRule",
35
+ "Rule",
36
+ "Target",
37
+ "TargetType",
38
+ "TargetingRules",
39
+ "Variation",
40
+ "assign_percentage",
41
+ "compare_array",
42
+ "compare_date",
43
+ "compare_numeric",
44
+ "compare_semver",
45
+ "compare_text",
46
+ "evaluate_condition",
47
+ ]
@@ -0,0 +1,40 @@
1
+ """Minimal RFC 6901 JSON Pointer resolution, used to read nested context traits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = ["find_by_pointer"]
8
+
9
+ _MISSING = object()
10
+
11
+
12
+ def find_by_pointer(pointer: str, document: Any) -> Any:
13
+ if not pointer.startswith("/"):
14
+ # Includes the empty pointer, which RFC 6901 defines as the whole document. Callers
15
+ # treat an empty trait path as "no trait", so it never reaches here.
16
+ return None
17
+
18
+ current = document
19
+ for raw_token in pointer[1:].split("/"):
20
+ # Unescape in this order: ~1 before ~0, so that "~01" resolves to "~1" and not "/".
21
+ token = raw_token.replace("~1", "/").replace("~0", "~")
22
+ current = _step(current, token)
23
+ if current is _MISSING:
24
+ return None
25
+ return current
26
+
27
+
28
+ def _step(current: Any, token: str) -> Any:
29
+ if isinstance(current, dict):
30
+ return current.get(token, _MISSING)
31
+ if isinstance(current, (list, tuple)):
32
+ try:
33
+ index = int(token)
34
+ except ValueError:
35
+ return _MISSING
36
+ # RFC 6901 array indexes are unsigned; Python's negative indexing must not leak in.
37
+ if index < 0 or index >= len(current):
38
+ return _MISSING
39
+ return current[index]
40
+ return _MISSING
@@ -0,0 +1,28 @@
1
+ """Rendering config values to strings the way the other ConfigDirector SDKs do.
2
+
3
+ Config values arrive as JSON and are handed back to the client as strings, so a boolean must
4
+ render as ``true`` rather than Python's ``True``, and a whole number as ``26`` rather than
5
+ ``26.0``. Getting this wrong would make the same config resolve to different text depending on
6
+ which SDK read it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any
13
+
14
+ __all__ = ["to_json_string"]
15
+
16
+
17
+ def to_json_string(value: Any) -> str:
18
+ if isinstance(value, bool):
19
+ return "true" if value else "false"
20
+ if isinstance(value, float):
21
+ if math.isnan(value):
22
+ return "NaN"
23
+ if math.isinf(value):
24
+ return "Infinity" if value > 0 else "-Infinity"
25
+ # JSON numbers have no int/float distinction, so 26.0 must render as "26".
26
+ if value.is_integer() and abs(value) < 1e21:
27
+ return str(int(value))
28
+ return str(value)
@@ -0,0 +1,115 @@
1
+ """A pure-Python port of rapidhash v3.0 ("fast" variant).
2
+
3
+ Percentage rollouts bucket users by hashing, so every ConfigDirector SDK must produce the exact
4
+ same 64-bit value for the same input or the same user would land in different buckets depending
5
+ on which SDK evaluated the config.
6
+
7
+ Do not "clean up" the arithmetic here. Every mask and shift is load-bearing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __all__ = ["rapidhash"]
13
+
14
+ _MASK64 = 0xFFFFFFFFFFFFFFFF
15
+
16
+ _SECRET = (
17
+ 0x2D358DCCAA6C78A5,
18
+ 0x8BB84B93962EACC9,
19
+ 0x4B33A62ED433D4A3,
20
+ 0x4D5A2DA51DE1AA47,
21
+ 0xA0761D6478BD642F,
22
+ 0xE7037ED1A0B428DB,
23
+ 0x90ED1765281C388C,
24
+ 0xAAAAAAAAAAAAAAAA,
25
+ )
26
+
27
+
28
+ def _mix(a: int, b: int) -> int:
29
+ m = a * b
30
+ return (m & _MASK64) ^ (m >> 64)
31
+
32
+
33
+ def _read64(data: bytes, offset: int) -> int:
34
+ return int.from_bytes(data[offset : offset + 8], "little")
35
+
36
+
37
+ def _read32(data: bytes, offset: int) -> int:
38
+ return int.from_bytes(data[offset : offset + 4], "little")
39
+
40
+
41
+ def _read_small(data: bytes, length: int) -> int:
42
+ v = data[0]
43
+ return data[length - 1] | (((v << 5) & 0xFF) << 40) | ((v >> 3) << 48)
44
+
45
+
46
+ def _epilogue(a: int, b: int, i: int) -> int:
47
+ m0 = a * b
48
+ m1 = ((m0 & _MASK64) ^ _SECRET[7]) * (((m0 >> 64) ^ _SECRET[1] ^ i) & _MASK64)
49
+ return (m1 & _MASK64) ^ (m1 >> 64)
50
+
51
+
52
+ def rapidhash(data: bytes, seed: int = 0) -> int:
53
+ length = len(data)
54
+ seed ^= _mix(seed ^ _SECRET[2], _SECRET[1])
55
+ i = length
56
+
57
+ if length <= 16:
58
+ bi = length
59
+ if length >= 4:
60
+ seed ^= length
61
+ if length >= 8:
62
+ a = _read64(data, 0)
63
+ b = _read64(data, length - 8)
64
+ else:
65
+ a = _read32(data, 0)
66
+ b = _read32(data, length - 4)
67
+ elif length > 0:
68
+ a = _read_small(data, length)
69
+ b = data[length >> 1]
70
+ else:
71
+ a = 0
72
+ b = 0
73
+ else:
74
+ p = 0
75
+ if i > 112:
76
+ see1 = see2 = see3 = see4 = see5 = see6 = seed
77
+ while True:
78
+ seed = _mix(_read64(data, p) ^ _SECRET[0], _read64(data, p + 8) ^ seed)
79
+ see1 = _mix(_read64(data, p + 16) ^ _SECRET[1], _read64(data, p + 24) ^ see1)
80
+ see2 = _mix(_read64(data, p + 32) ^ _SECRET[2], _read64(data, p + 40) ^ see2)
81
+ see3 = _mix(_read64(data, p + 48) ^ _SECRET[3], _read64(data, p + 56) ^ see3)
82
+ see4 = _mix(_read64(data, p + 64) ^ _SECRET[4], _read64(data, p + 72) ^ see4)
83
+ see5 = _mix(_read64(data, p + 80) ^ _SECRET[5], _read64(data, p + 88) ^ see5)
84
+ see6 = _mix(_read64(data, p + 96) ^ _SECRET[6], _read64(data, p + 104) ^ see6)
85
+ p += 112
86
+ i -= 112
87
+ if i <= 112:
88
+ break
89
+ seed ^= see1
90
+ see2 ^= see3
91
+ see4 ^= see5
92
+ seed ^= see6
93
+ see2 ^= see4
94
+ seed ^= see2
95
+
96
+ bi = i
97
+ if i > 16:
98
+ seed = _mix(_read64(data, p) ^ _SECRET[2], _read64(data, p + 8) ^ seed)
99
+ if i > 32:
100
+ seed = _mix(_read64(data, p + 16) ^ _SECRET[2], _read64(data, p + 24) ^ seed)
101
+ if i > 48:
102
+ seed = _mix(_read64(data, p + 32) ^ _SECRET[1], _read64(data, p + 40) ^ seed)
103
+ if i > 64:
104
+ seed = _mix(_read64(data, p + 48) ^ _SECRET[1], _read64(data, p + 56) ^ seed)
105
+ if i > 80:
106
+ seed = _mix(_read64(data, p + 64) ^ _SECRET[2], _read64(data, p + 72) ^ seed)
107
+ if i > 96:
108
+ seed = _mix(_read64(data, p + 80) ^ _SECRET[1], _read64(data, p + 88) ^ seed)
109
+
110
+ a = _read64(data, p + i - 16) ^ bi
111
+ b = _read64(data, p + i - 8)
112
+
113
+ a ^= _SECRET[1]
114
+ b ^= seed
115
+ return _epilogue(a, b, bi)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ._json_value import to_json_string
6
+
7
+ __all__ = ["compare_array"]
8
+
9
+
10
+ def compare_array(value: Any, operator: str, target_values: list[str]) -> bool:
11
+ lowercase_operator = operator.lower()
12
+ if not isinstance(value, list):
13
+ return lowercase_operator == "does not contain any of"
14
+
15
+ # Nested arrays, objects, and nulls have no text form, so they are dropped rather than
16
+ # matching an empty target value.
17
+ elements = [to_json_string(item) for item in value if isinstance(item, (str, int, float, bool))]
18
+
19
+ match lowercase_operator:
20
+ case "contains any of":
21
+ return any(element in target_values for element in elements)
22
+ case "does not contain any of":
23
+ return not any(element in target_values for element in elements)
24
+ case _:
25
+ return False
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..types import Context, Metadata
6
+ from ._json_pointer import find_by_pointer
7
+ from ._json_value import to_json_string
8
+ from .array_comparison import compare_array
9
+ from .date_comparison import compare_date
10
+ from .numeric_comparison import compare_numeric
11
+ from .semver_comparison import compare_semver
12
+ from .text_comparison import compare_text
13
+ from .types import Condition, EvaluationContext
14
+
15
+ __all__ = ["evaluate_condition"]
16
+
17
+ # The context does not carry this attribute. It is compared as "" rather than skipped, so that a
18
+ # negative operator such as "does NOT equal" can still match.
19
+ _ABSENT = object()
20
+
21
+ # The condition names an attribute this SDK version does not know about. Unlike an absent value,
22
+ # it is not compared at all — there is no sensible thing to compare.
23
+ _UNKNOWN_ATTRIBUTE = object()
24
+
25
+ _EMPTY_CONTEXT = Context()
26
+ _EMPTY_METADATA = Metadata()
27
+
28
+
29
+ def evaluate_condition(condition: Condition, context: EvaluationContext | None = None) -> bool:
30
+ value = _resolve(condition, context)
31
+ if value is _UNKNOWN_ATTRIBUTE:
32
+ return False
33
+
34
+ targets = condition.target_values or []
35
+
36
+ match condition.target_type:
37
+ case "text":
38
+ return compare_text(_render(value), condition.operator, targets)
39
+ case "number":
40
+ return compare_numeric(_unwrap(value), condition.operator, targets)
41
+ case "datetime":
42
+ return compare_date(_render(value), condition.operator, targets)
43
+ case "semver":
44
+ return compare_semver(_render(value), condition.operator, targets)
45
+ case "array":
46
+ return compare_array(_unwrap(value), condition.operator, targets)
47
+ case _:
48
+ return False
49
+
50
+
51
+ def _resolve(condition: Condition, context: EvaluationContext | None) -> Any:
52
+ match condition.attribute:
53
+ case "identifier":
54
+ return _or_absent(_context_of(context).id)
55
+ case "name":
56
+ return _or_absent(_context_of(context).name)
57
+ case "appName":
58
+ return _or_absent(_metadata_of(context).app_name)
59
+ case "appVersion":
60
+ return _or_absent(_metadata_of(context).app_version)
61
+ case "traits":
62
+ if not condition.trait:
63
+ return _ABSENT
64
+ return _or_absent(find_by_pointer(condition.trait, _context_of(context).traits))
65
+ case _:
66
+ return _UNKNOWN_ATTRIBUTE
67
+
68
+
69
+ def _context_of(context: EvaluationContext | None) -> Context:
70
+ if context is None or context.context is None:
71
+ return _EMPTY_CONTEXT
72
+ return context.context
73
+
74
+
75
+ def _metadata_of(context: EvaluationContext | None) -> Metadata:
76
+ if context is None or context.metadata is None:
77
+ return _EMPTY_METADATA
78
+ return context.metadata
79
+
80
+
81
+ def _or_absent(value: Any) -> Any:
82
+ return _ABSENT if value is None else value
83
+
84
+
85
+ def _unwrap(value: Any) -> Any:
86
+ return None if value is _ABSENT else value
87
+
88
+
89
+ def _render(value: Any) -> str:
90
+ if value is _ABSENT or value is None:
91
+ return ""
92
+ if isinstance(value, (str, int, float, bool)):
93
+ return to_json_string(value)
94
+ return "" # arrays and objects