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 ADDED
@@ -0,0 +1,12 @@
1
+ from .client import Client, EnvflagsError, create_client
2
+ from .evaluate import Context, Payload, bucket, evaluate
3
+
4
+ __all__ = [
5
+ "Client",
6
+ "Context",
7
+ "EnvflagsError",
8
+ "Payload",
9
+ "bucket",
10
+ "create_client",
11
+ "evaluate",
12
+ ]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ envflags