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.
- configdirector/__init__.py +83 -0
- configdirector/_bundle.py +176 -0
- configdirector/_evaluation/__init__.py +47 -0
- configdirector/_evaluation/_json_pointer.py +40 -0
- configdirector/_evaluation/_json_value.py +28 -0
- configdirector/_evaluation/_rapidhash.py +115 -0
- configdirector/_evaluation/array_comparison.py +25 -0
- configdirector/_evaluation/condition_evaluator.py +94 -0
- configdirector/_evaluation/config_evaluator.py +123 -0
- configdirector/_evaluation/date_comparison.py +77 -0
- configdirector/_evaluation/numeric_comparison.py +61 -0
- configdirector/_evaluation/percent_hashing.py +12 -0
- configdirector/_evaluation/semver_comparison.py +63 -0
- configdirector/_evaluation/text_comparison.py +40 -0
- configdirector/_evaluation/types.py +134 -0
- configdirector/_eventsource/__init__.py +27 -0
- configdirector/_eventsource/client.py +350 -0
- configdirector/_eventsource/errors.py +31 -0
- configdirector/_eventsource/parser.py +175 -0
- configdirector/_eventsource/transport.py +162 -0
- configdirector/_eventsource/types.py +58 -0
- configdirector/_http.py +86 -0
- configdirector/_logger.py +12 -0
- configdirector/_telemetry/__init__.py +49 -0
- configdirector/_telemetry/collector.py +188 -0
- configdirector/_telemetry/compact_json.py +40 -0
- configdirector/_telemetry/events.py +156 -0
- configdirector/_telemetry/queue.py +126 -0
- configdirector/_telemetry/reporter.py +157 -0
- configdirector/_telemetry/value_id.py +30 -0
- configdirector/_transport/__init__.py +23 -0
- configdirector/_transport/base.py +84 -0
- configdirector/_transport/polling.py +159 -0
- configdirector/_transport/streaming.py +110 -0
- configdirector/_value_parser.py +94 -0
- configdirector/_version.py +3 -0
- configdirector/client.py +640 -0
- configdirector/errors.py +45 -0
- configdirector/py.typed +0 -0
- configdirector/types.py +552 -0
- configdirector_server_sdk-0.2.0.dist-info/METADATA +49 -0
- configdirector_server_sdk-0.2.0.dist-info/RECORD +44 -0
- configdirector_server_sdk-0.2.0.dist-info/WHEEL +4 -0
- 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
|