envflags 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.
- envflags/__init__.py +12 -0
- envflags/client.py +307 -0
- envflags/evaluate.py +312 -0
- envflags-0.1.0.dist-info/METADATA +38 -0
- envflags-0.1.0.dist-info/RECORD +7 -0
- envflags-0.1.0.dist-info/WHEEL +5 -0
- envflags-0.1.0.dist-info/top_level.txt +1 -0
envflags/__init__.py
ADDED
envflags/client.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple, TypeVar
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.parse import urljoin, urlparse
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from .evaluate import Context, Payload, evaluate
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://envflags.com"
|
|
14
|
+
DEFAULT_POLL_INTERVAL = 30.0
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EnvflagsError(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Client:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
sdk_key: str,
|
|
26
|
+
*,
|
|
27
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
28
|
+
bootstrap: Optional[Payload] = None,
|
|
29
|
+
opener: Callable[[Request], Any] = urlopen,
|
|
30
|
+
on_error: Optional[Callable[[Exception], None]] = None,
|
|
31
|
+
poll_interval: Optional[float] = DEFAULT_POLL_INTERVAL,
|
|
32
|
+
) -> None:
|
|
33
|
+
normalized_key = sdk_key.strip()
|
|
34
|
+
if not normalized_key:
|
|
35
|
+
raise ValueError("an envflags SDK key is required")
|
|
36
|
+
parsed_url = urlparse(base_url)
|
|
37
|
+
if not parsed_url.scheme or not parsed_url.netloc:
|
|
38
|
+
raise ValueError("envflags base_url must be an absolute URL")
|
|
39
|
+
if poll_interval is not None and (
|
|
40
|
+
isinstance(poll_interval, bool)
|
|
41
|
+
or not isinstance(poll_interval, (int, float))
|
|
42
|
+
or not math.isfinite(float(poll_interval))
|
|
43
|
+
or poll_interval < 0
|
|
44
|
+
):
|
|
45
|
+
raise ValueError("poll_interval must be a non-negative finite number or None")
|
|
46
|
+
if bootstrap is not None:
|
|
47
|
+
_validate_payload(bootstrap, "bootstrap payload")
|
|
48
|
+
|
|
49
|
+
self._sdk_key = normalized_key
|
|
50
|
+
self._base_url = base_url
|
|
51
|
+
self._opener = opener
|
|
52
|
+
self._on_error = on_error or (lambda error: None)
|
|
53
|
+
self._poll_interval = (
|
|
54
|
+
float(poll_interval) if poll_interval is not None else None
|
|
55
|
+
)
|
|
56
|
+
self._payload: Optional[Payload] = dict(bootstrap) if bootstrap else None
|
|
57
|
+
self._etag: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
self._condition = threading.Condition(threading.RLock())
|
|
60
|
+
self._refreshing = False
|
|
61
|
+
self._refresh_result = False
|
|
62
|
+
self._refresh_error: Optional[Exception] = None
|
|
63
|
+
self._stop_event = threading.Event()
|
|
64
|
+
self._poll_thread: Optional[threading.Thread] = None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def initialized(self) -> bool:
|
|
68
|
+
with self._condition:
|
|
69
|
+
return self._payload is not None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def version(self) -> Optional[int]:
|
|
73
|
+
with self._condition:
|
|
74
|
+
if self._payload is None:
|
|
75
|
+
return None
|
|
76
|
+
value = self._payload.get("version")
|
|
77
|
+
return value if _is_integer(value) else None
|
|
78
|
+
|
|
79
|
+
def initialize(self) -> "Client":
|
|
80
|
+
try:
|
|
81
|
+
self.refresh()
|
|
82
|
+
except Exception as error:
|
|
83
|
+
self._on_error(error)
|
|
84
|
+
if not self.initialized:
|
|
85
|
+
raise
|
|
86
|
+
self._start_polling()
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def refresh(self) -> bool:
|
|
90
|
+
"""Poll and atomically install a new payload when its version changes."""
|
|
91
|
+
with self._condition:
|
|
92
|
+
if self._refreshing:
|
|
93
|
+
while self._refreshing:
|
|
94
|
+
self._condition.wait()
|
|
95
|
+
if self._refresh_error is not None:
|
|
96
|
+
raise self._refresh_error
|
|
97
|
+
return self._refresh_result
|
|
98
|
+
self._refreshing = True
|
|
99
|
+
|
|
100
|
+
changed = False
|
|
101
|
+
error: Optional[Exception] = None
|
|
102
|
+
try:
|
|
103
|
+
changed = self._perform_refresh()
|
|
104
|
+
return changed
|
|
105
|
+
except Exception as caught:
|
|
106
|
+
error = caught
|
|
107
|
+
raise
|
|
108
|
+
finally:
|
|
109
|
+
with self._condition:
|
|
110
|
+
self._refresh_result = changed
|
|
111
|
+
self._refresh_error = error
|
|
112
|
+
self._refreshing = False
|
|
113
|
+
self._condition.notify_all()
|
|
114
|
+
|
|
115
|
+
def close(self) -> None:
|
|
116
|
+
self._stop_event.set()
|
|
117
|
+
with self._condition:
|
|
118
|
+
thread = self._poll_thread
|
|
119
|
+
self._poll_thread = None
|
|
120
|
+
if thread is not None and thread is not threading.current_thread():
|
|
121
|
+
thread.join()
|
|
122
|
+
|
|
123
|
+
def variation(
|
|
124
|
+
self, flag_key: str, context: Context, default_value: T
|
|
125
|
+
) -> Any:
|
|
126
|
+
with self._condition:
|
|
127
|
+
payload = self._payload
|
|
128
|
+
if payload is None:
|
|
129
|
+
return default_value
|
|
130
|
+
return evaluate(payload, flag_key, context, default_value)
|
|
131
|
+
|
|
132
|
+
def bool_variation(
|
|
133
|
+
self, flag_key: str, context: Context, default_value: bool
|
|
134
|
+
) -> bool:
|
|
135
|
+
value = self.variation(flag_key, context, default_value)
|
|
136
|
+
return value if isinstance(value, bool) else default_value
|
|
137
|
+
|
|
138
|
+
def string_variation(
|
|
139
|
+
self, flag_key: str, context: Context, default_value: str
|
|
140
|
+
) -> str:
|
|
141
|
+
value = self.variation(flag_key, context, default_value)
|
|
142
|
+
return value if isinstance(value, str) else default_value
|
|
143
|
+
|
|
144
|
+
def number_variation(
|
|
145
|
+
self, flag_key: str, context: Context, default_value: float
|
|
146
|
+
) -> float:
|
|
147
|
+
value = self.variation(flag_key, context, default_value)
|
|
148
|
+
if (
|
|
149
|
+
isinstance(value, bool)
|
|
150
|
+
or not isinstance(value, (int, float))
|
|
151
|
+
or not math.isfinite(float(value))
|
|
152
|
+
):
|
|
153
|
+
return default_value
|
|
154
|
+
return float(value)
|
|
155
|
+
|
|
156
|
+
def json_variation(
|
|
157
|
+
self, flag_key: str, context: Context, default_value: T
|
|
158
|
+
) -> Any:
|
|
159
|
+
return self.variation(flag_key, context, default_value)
|
|
160
|
+
|
|
161
|
+
def _perform_refresh(self) -> bool:
|
|
162
|
+
with self._condition:
|
|
163
|
+
etag = self._etag
|
|
164
|
+
current_payload = self._payload
|
|
165
|
+
|
|
166
|
+
headers = self._headers()
|
|
167
|
+
if etag:
|
|
168
|
+
headers["If-None-Match"] = etag
|
|
169
|
+
status, pointer = self._request_json(
|
|
170
|
+
urljoin(self._base_url, "/sdk/poll"),
|
|
171
|
+
headers,
|
|
172
|
+
"poll for flag updates",
|
|
173
|
+
allow_not_modified=True,
|
|
174
|
+
)
|
|
175
|
+
if status == 304:
|
|
176
|
+
return False
|
|
177
|
+
_validate_pointer(pointer)
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
current_payload is not None
|
|
181
|
+
and current_payload.get("envId") == pointer["envId"]
|
|
182
|
+
and current_payload.get("version") == pointer["version"]
|
|
183
|
+
):
|
|
184
|
+
with self._condition:
|
|
185
|
+
self._etag = pointer["etag"]
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
_, payload = self._request_json(
|
|
189
|
+
urljoin(self._base_url, pointer["payloadUrl"]),
|
|
190
|
+
self._headers(),
|
|
191
|
+
"fetch the flag payload",
|
|
192
|
+
)
|
|
193
|
+
_validate_payload(payload, "payload")
|
|
194
|
+
if (
|
|
195
|
+
payload["envId"] != pointer["envId"]
|
|
196
|
+
or payload["version"] != pointer["version"]
|
|
197
|
+
):
|
|
198
|
+
raise EnvflagsError(
|
|
199
|
+
"the envflags payload does not match its version pointer"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
with self._condition:
|
|
203
|
+
self._payload = payload
|
|
204
|
+
self._etag = pointer["etag"]
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
def _request_json(
|
|
208
|
+
self,
|
|
209
|
+
url: str,
|
|
210
|
+
headers: Dict[str, str],
|
|
211
|
+
action: str,
|
|
212
|
+
*,
|
|
213
|
+
allow_not_modified: bool = False,
|
|
214
|
+
) -> Tuple[int, Any]:
|
|
215
|
+
request = Request(url, headers=headers, method="GET")
|
|
216
|
+
try:
|
|
217
|
+
with self._opener(request) as response:
|
|
218
|
+
status = response.status
|
|
219
|
+
if allow_not_modified and status == 304:
|
|
220
|
+
return status, None
|
|
221
|
+
if status < 200 or status >= 300:
|
|
222
|
+
raise EnvflagsError(
|
|
223
|
+
f"unable to {action}: envflags returned {status}"
|
|
224
|
+
)
|
|
225
|
+
try:
|
|
226
|
+
return status, json.loads(response.read().decode("utf-8"))
|
|
227
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
228
|
+
raise EnvflagsError(
|
|
229
|
+
f"the envflags response for {action} is invalid JSON"
|
|
230
|
+
) from error
|
|
231
|
+
except HTTPError as error:
|
|
232
|
+
if allow_not_modified and error.code == 304:
|
|
233
|
+
error.close()
|
|
234
|
+
return 304, None
|
|
235
|
+
error.close()
|
|
236
|
+
raise EnvflagsError(
|
|
237
|
+
f"unable to {action}: envflags returned {error.code} {error.reason}"
|
|
238
|
+
) from error
|
|
239
|
+
except URLError as error:
|
|
240
|
+
raise EnvflagsError(f"unable to {action}: {error.reason}") from error
|
|
241
|
+
|
|
242
|
+
def _headers(self) -> Dict[str, str]:
|
|
243
|
+
return {
|
|
244
|
+
"Accept": "application/json",
|
|
245
|
+
"Authorization": f"Bearer {self._sdk_key}",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def _start_polling(self) -> None:
|
|
249
|
+
if self._poll_interval is None or self._poll_interval == 0:
|
|
250
|
+
return
|
|
251
|
+
with self._condition:
|
|
252
|
+
if self._poll_thread is not None:
|
|
253
|
+
return
|
|
254
|
+
self._stop_event.clear()
|
|
255
|
+
thread = threading.Thread(
|
|
256
|
+
target=self._poll,
|
|
257
|
+
name="envflags-poll",
|
|
258
|
+
daemon=True,
|
|
259
|
+
)
|
|
260
|
+
self._poll_thread = thread
|
|
261
|
+
thread.start()
|
|
262
|
+
|
|
263
|
+
def _poll(self) -> None:
|
|
264
|
+
assert self._poll_interval is not None
|
|
265
|
+
while not self._stop_event.wait(self._poll_interval):
|
|
266
|
+
try:
|
|
267
|
+
self.refresh()
|
|
268
|
+
except Exception as error:
|
|
269
|
+
self._on_error(error)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def create_client(sdk_key: str, **options: Any) -> Client:
|
|
273
|
+
return Client(sdk_key, **options)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _validate_pointer(value: Any) -> None:
|
|
277
|
+
if (
|
|
278
|
+
not isinstance(value, Mapping)
|
|
279
|
+
or not isinstance(value.get("envId"), str)
|
|
280
|
+
or not value["envId"]
|
|
281
|
+
or not _is_integer(value.get("version"))
|
|
282
|
+
or value["version"] < 0
|
|
283
|
+
or not isinstance(value.get("etag"), str)
|
|
284
|
+
or not value["etag"]
|
|
285
|
+
or not isinstance(value.get("payloadUrl"), str)
|
|
286
|
+
or not value["payloadUrl"]
|
|
287
|
+
):
|
|
288
|
+
raise EnvflagsError("the envflags poll response is invalid")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _validate_payload(value: Any, label: str) -> None:
|
|
292
|
+
if (
|
|
293
|
+
not isinstance(value, Mapping)
|
|
294
|
+
or not isinstance(value.get("envId"), str)
|
|
295
|
+
or not value["envId"]
|
|
296
|
+
or not _is_integer(value.get("version"))
|
|
297
|
+
or value["version"] < 0
|
|
298
|
+
or not isinstance(value.get("salt"), str)
|
|
299
|
+
or not value["salt"]
|
|
300
|
+
or not isinstance(value.get("flags"), Mapping)
|
|
301
|
+
or not isinstance(value.get("segments"), Mapping)
|
|
302
|
+
):
|
|
303
|
+
raise EnvflagsError(f"the envflags {label} is invalid")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _is_integer(value: Any) -> bool:
|
|
307
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
envflags/evaluate.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Any, Dict, Iterable, Mapping, Optional, Set
|
|
8
|
+
|
|
9
|
+
BUCKET_DENOMINATOR = 1152921504606846975.0
|
|
10
|
+
RFC3339_PATTERN = re.compile(
|
|
11
|
+
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
Context = Mapping[str, Any]
|
|
15
|
+
Payload = Mapping[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def bucket(flag_key: str, salt: str, bucket_by: str) -> float:
|
|
19
|
+
"""Return the context's bucket using the frozen v1 algorithm."""
|
|
20
|
+
value = f"{flag_key}.{salt}.{bucket_by}".encode("utf-8")
|
|
21
|
+
digest = hashlib.sha1(value).hexdigest()
|
|
22
|
+
return float(int(digest[:15], 16)) / BUCKET_DENOMINATOR
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def evaluate(
|
|
26
|
+
payload: Payload,
|
|
27
|
+
flag_key: str,
|
|
28
|
+
context: Context,
|
|
29
|
+
default_value: Any,
|
|
30
|
+
) -> Any:
|
|
31
|
+
"""Evaluate one flag locally, falling back when no variation is valid."""
|
|
32
|
+
flags = payload.get("flags")
|
|
33
|
+
flag = flags.get(flag_key) if isinstance(flags, Mapping) else None
|
|
34
|
+
key = context.get("key")
|
|
35
|
+
if not isinstance(flag, Mapping) or not isinstance(key, str) or not key:
|
|
36
|
+
return default_value
|
|
37
|
+
|
|
38
|
+
variation: Optional[int] = None
|
|
39
|
+
if flag.get("on") is not True:
|
|
40
|
+
variation = _index(flag.get("offVariation"))
|
|
41
|
+
else:
|
|
42
|
+
targets = flag.get("targets")
|
|
43
|
+
if isinstance(targets, list):
|
|
44
|
+
for target in targets:
|
|
45
|
+
if not isinstance(target, Mapping):
|
|
46
|
+
continue
|
|
47
|
+
values = target.get("values")
|
|
48
|
+
if isinstance(values, list) and key in values:
|
|
49
|
+
variation = _index(target.get("variation"))
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
if variation is None:
|
|
53
|
+
rules = flag.get("rules")
|
|
54
|
+
if isinstance(rules, list):
|
|
55
|
+
for rule in rules:
|
|
56
|
+
if not isinstance(rule, Mapping):
|
|
57
|
+
continue
|
|
58
|
+
clauses = rule.get("clauses")
|
|
59
|
+
if isinstance(clauses, list) and _clauses_match(
|
|
60
|
+
clauses, context, payload, set()
|
|
61
|
+
):
|
|
62
|
+
variation = _select_variation(
|
|
63
|
+
rule, flag_key, context, payload
|
|
64
|
+
)
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
if variation is None:
|
|
68
|
+
fallthrough = flag.get("fallthrough")
|
|
69
|
+
if isinstance(fallthrough, Mapping):
|
|
70
|
+
variation = _select_variation(
|
|
71
|
+
fallthrough, flag_key, context, payload
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
variations = flag.get("variations")
|
|
75
|
+
if (
|
|
76
|
+
variation is None
|
|
77
|
+
or not isinstance(variations, list)
|
|
78
|
+
or variation < 0
|
|
79
|
+
or variation >= len(variations)
|
|
80
|
+
):
|
|
81
|
+
return default_value
|
|
82
|
+
return variations[variation]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _select_variation(
|
|
86
|
+
selector: Mapping[str, Any],
|
|
87
|
+
flag_key: str,
|
|
88
|
+
context: Context,
|
|
89
|
+
payload: Payload,
|
|
90
|
+
) -> Optional[int]:
|
|
91
|
+
if "variation" in selector:
|
|
92
|
+
return _index(selector.get("variation"))
|
|
93
|
+
|
|
94
|
+
rollout = selector.get("rollout")
|
|
95
|
+
if not isinstance(rollout, list) or not rollout:
|
|
96
|
+
return None
|
|
97
|
+
last = rollout[-1]
|
|
98
|
+
if not isinstance(last, Mapping):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
bucket_by = selector.get("bucketBy", "key")
|
|
102
|
+
if not isinstance(bucket_by, str):
|
|
103
|
+
return None
|
|
104
|
+
if bucket_by not in context:
|
|
105
|
+
return _index(last.get("variation"))
|
|
106
|
+
|
|
107
|
+
context_bucket = bucket(
|
|
108
|
+
flag_key,
|
|
109
|
+
str(payload.get("salt", "")),
|
|
110
|
+
_stringify(context[bucket_by]),
|
|
111
|
+
)
|
|
112
|
+
accumulated_weight = 0.0
|
|
113
|
+
for entry in rollout:
|
|
114
|
+
if not isinstance(entry, Mapping):
|
|
115
|
+
continue
|
|
116
|
+
weight = entry.get("weight")
|
|
117
|
+
if not _is_number(weight) or not math.isfinite(float(weight)):
|
|
118
|
+
continue
|
|
119
|
+
accumulated_weight += float(weight) / 100_000.0
|
|
120
|
+
if context_bucket < accumulated_weight:
|
|
121
|
+
return _index(entry.get("variation"))
|
|
122
|
+
return _index(last.get("variation"))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _clauses_match(
|
|
126
|
+
clauses: Iterable[Any],
|
|
127
|
+
context: Context,
|
|
128
|
+
payload: Payload,
|
|
129
|
+
visiting: Set[str],
|
|
130
|
+
) -> bool:
|
|
131
|
+
return all(
|
|
132
|
+
isinstance(clause, Mapping)
|
|
133
|
+
and _clause_matches(clause, context, payload, visiting)
|
|
134
|
+
for clause in clauses
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _clause_matches(
|
|
139
|
+
clause: Mapping[str, Any],
|
|
140
|
+
context: Context,
|
|
141
|
+
payload: Payload,
|
|
142
|
+
visiting: Set[str],
|
|
143
|
+
) -> bool:
|
|
144
|
+
attribute_name = clause.get("attribute")
|
|
145
|
+
if not isinstance(attribute_name, str) or attribute_name not in context:
|
|
146
|
+
return False
|
|
147
|
+
attribute = context[attribute_name]
|
|
148
|
+
values = clause.get("values")
|
|
149
|
+
if not isinstance(values, list):
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
operation = clause.get("op")
|
|
153
|
+
if operation == "in":
|
|
154
|
+
return _matches_in(attribute, values)
|
|
155
|
+
if operation == "notIn":
|
|
156
|
+
return not _matches_in(attribute, values)
|
|
157
|
+
if operation == "startsWith":
|
|
158
|
+
return _matches_strings(attribute, values, str.startswith)
|
|
159
|
+
if operation == "endsWith":
|
|
160
|
+
return _matches_strings(attribute, values, str.endswith)
|
|
161
|
+
if operation == "contains":
|
|
162
|
+
return _matches_strings(attribute, values, lambda left, right: right in left)
|
|
163
|
+
if operation == "matches":
|
|
164
|
+
return _matches_strings(attribute, values, _regex_search)
|
|
165
|
+
if operation == "lessThan":
|
|
166
|
+
return _matches_numbers(attribute, values, lambda left, right: left < right)
|
|
167
|
+
if operation == "greaterThan":
|
|
168
|
+
return _matches_numbers(attribute, values, lambda left, right: left > right)
|
|
169
|
+
if operation == "before":
|
|
170
|
+
return _matches_dates(attribute, values, lambda left, right: left < right)
|
|
171
|
+
if operation == "after":
|
|
172
|
+
return _matches_dates(attribute, values, lambda left, right: left > right)
|
|
173
|
+
if operation == "segmentMatch":
|
|
174
|
+
return _matches_segments(values, context, payload, visiting)
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _matches_in(attribute: Any, values: Iterable[Any]) -> bool:
|
|
179
|
+
attributes = attribute if isinstance(attribute, list) else [attribute]
|
|
180
|
+
return any(_scalar_equal(item, value) for item in attributes for value in values)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _scalar_equal(left: Any, right: Any) -> bool:
|
|
184
|
+
if left is None or right is None:
|
|
185
|
+
return left is None and right is None
|
|
186
|
+
if isinstance(left, bool) or isinstance(right, bool):
|
|
187
|
+
return isinstance(left, bool) and isinstance(right, bool) and left == right
|
|
188
|
+
if _is_number(left) or _is_number(right):
|
|
189
|
+
return _is_number(left) and _is_number(right) and float(left) == float(right)
|
|
190
|
+
if isinstance(left, str) or isinstance(right, str):
|
|
191
|
+
return isinstance(left, str) and isinstance(right, str) and left == right
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _matches_strings(attribute: Any, values: Iterable[Any], predicate: Any) -> bool:
|
|
196
|
+
if not isinstance(attribute, str):
|
|
197
|
+
return False
|
|
198
|
+
return any(isinstance(value, str) and predicate(attribute, value) for value in values)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _matches_numbers(attribute: Any, values: Iterable[Any], predicate: Any) -> bool:
|
|
202
|
+
if not _is_finite_number(attribute):
|
|
203
|
+
return False
|
|
204
|
+
return any(
|
|
205
|
+
_is_finite_number(value) and predicate(float(attribute), float(value))
|
|
206
|
+
for value in values
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _matches_dates(attribute: Any, values: Iterable[Any], predicate: Any) -> bool:
|
|
211
|
+
left = _instant(attribute)
|
|
212
|
+
if left is None:
|
|
213
|
+
return False
|
|
214
|
+
return any(
|
|
215
|
+
right is not None and predicate(left, right)
|
|
216
|
+
for right in (_instant(value) for value in values)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _matches_segments(
|
|
221
|
+
values: Iterable[Any],
|
|
222
|
+
context: Context,
|
|
223
|
+
payload: Payload,
|
|
224
|
+
visiting: Set[str],
|
|
225
|
+
) -> bool:
|
|
226
|
+
segments = payload.get("segments")
|
|
227
|
+
if not isinstance(segments, Mapping):
|
|
228
|
+
return False
|
|
229
|
+
for value in values:
|
|
230
|
+
if not isinstance(value, str) or value in visiting:
|
|
231
|
+
continue
|
|
232
|
+
segment = segments.get(value)
|
|
233
|
+
if not isinstance(segment, Mapping):
|
|
234
|
+
continue
|
|
235
|
+
if _segment_matches(segment, context, payload, visiting | {value}):
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _segment_matches(
|
|
241
|
+
segment: Mapping[str, Any],
|
|
242
|
+
context: Context,
|
|
243
|
+
payload: Payload,
|
|
244
|
+
visiting: Set[str],
|
|
245
|
+
) -> bool:
|
|
246
|
+
key = context.get("key")
|
|
247
|
+
excluded = segment.get("excluded")
|
|
248
|
+
if isinstance(excluded, list) and key in excluded:
|
|
249
|
+
return False
|
|
250
|
+
included = segment.get("included")
|
|
251
|
+
if isinstance(included, list) and key in included:
|
|
252
|
+
return True
|
|
253
|
+
rules = segment.get("rules")
|
|
254
|
+
if not isinstance(rules, list):
|
|
255
|
+
return False
|
|
256
|
+
return any(
|
|
257
|
+
isinstance(rule, Mapping)
|
|
258
|
+
and isinstance(rule.get("clauses"), list)
|
|
259
|
+
and _clauses_match(rule["clauses"], context, payload, visiting)
|
|
260
|
+
for rule in rules
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _regex_search(left: str, pattern: str) -> bool:
|
|
265
|
+
try:
|
|
266
|
+
return re.search(pattern, left) is not None
|
|
267
|
+
except re.error:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _instant(value: Any) -> Optional[float]:
|
|
272
|
+
if _is_finite_number(value):
|
|
273
|
+
return float(value)
|
|
274
|
+
if not isinstance(value, str) or not RFC3339_PATTERN.fullmatch(value):
|
|
275
|
+
return None
|
|
276
|
+
try:
|
|
277
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
278
|
+
parsed = datetime.fromisoformat(normalized)
|
|
279
|
+
return parsed.timestamp() * 1000.0
|
|
280
|
+
except ValueError:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _index(value: Any) -> Optional[int]:
|
|
285
|
+
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _is_number(value: Any) -> bool:
|
|
289
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _is_finite_number(value: Any) -> bool:
|
|
293
|
+
return _is_number(value) and math.isfinite(float(value))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _stringify(value: Any) -> str:
|
|
297
|
+
if value is None:
|
|
298
|
+
return "null"
|
|
299
|
+
if isinstance(value, bool):
|
|
300
|
+
return "true" if value else "false"
|
|
301
|
+
if isinstance(value, str):
|
|
302
|
+
return value
|
|
303
|
+
if _is_number(value):
|
|
304
|
+
numeric = float(value)
|
|
305
|
+
if numeric == 0:
|
|
306
|
+
return "0"
|
|
307
|
+
if numeric.is_integer():
|
|
308
|
+
return str(int(numeric))
|
|
309
|
+
return repr(numeric)
|
|
310
|
+
if isinstance(value, list):
|
|
311
|
+
return ",".join(_stringify(item) for item in value)
|
|
312
|
+
return "[object Object]"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envflags
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for envflags
|
|
5
|
+
Author: envflags
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://envflags.com
|
|
8
|
+
Project-URL: Repository, https://github.com/envflags/envflags
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# envflags Python SDK
|
|
13
|
+
|
|
14
|
+
The Python SDK downloads an immutable delivery payload, keeps the last known
|
|
15
|
+
good copy in memory, and evaluates flags locally using the shared v1 spec.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from envflags import create_client
|
|
19
|
+
|
|
20
|
+
flags = create_client("envflags_server_...").initialize()
|
|
21
|
+
try:
|
|
22
|
+
enabled = flags.bool_variation(
|
|
23
|
+
"new-checkout",
|
|
24
|
+
{"key": "user-123", "country": "BE"},
|
|
25
|
+
False,
|
|
26
|
+
)
|
|
27
|
+
finally:
|
|
28
|
+
flags.close()
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The client polls every 30 seconds and fetches a payload only when its version
|
|
32
|
+
changes. Set `poll_interval=None` to refresh manually. Supply a previously
|
|
33
|
+
persisted payload through `bootstrap` to remain available when initialization
|
|
34
|
+
is offline. Refresh failures never replace the last known good payload.
|
|
35
|
+
|
|
36
|
+
The package supports Python 3.9+ and has no third-party dependencies. `evaluate`
|
|
37
|
+
and `bucket` are exported for applications that manage payload delivery
|
|
38
|
+
themselves.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
envflags/__init__.py,sha256=jS6u_cnaq9YN9xACCqEF-TGUkwG6dp_TiCtlozKH8UU,245
|
|
2
|
+
envflags/client.py,sha256=hK05lQDuUL8Xrmev8cM_7ZHAn04ZFQcf9VjTiU6poYM,10398
|
|
3
|
+
envflags/evaluate.py,sha256=n-rG7PeHCzu26x6TEaKbUoAU9s1g2INDQXpXNNahhf0,10101
|
|
4
|
+
envflags-0.1.0.dist-info/METADATA,sha256=04_fkp4QqfE5pzG9SQ54RFfsOH_fOxYi9hBBCmR83P8,1198
|
|
5
|
+
envflags-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
envflags-0.1.0.dist-info/top_level.txt,sha256=KYHnMbp984CUTngOIZ85jBqK4pCuBHQcJ_txZuQA1Gs,9
|
|
7
|
+
envflags-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
envflags
|