azfive-capture 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.
@@ -0,0 +1,11 @@
1
+ """azfive-capture — AzFive product analytics for Python servers.
2
+
3
+ Events, identity, super properties and feature flags/experiments against a
4
+ AzFive deployment. See ``sdk-spec/SPEC.md`` in the AzFive repo for the
5
+ cross-SDK behavioral contract.
6
+ """
7
+
8
+ from .client import LIB_NAME, LIB_VERSION, Client, create_client
9
+ from .flags import FlagValue
10
+
11
+ __all__ = ["Client", "create_client", "FlagValue", "LIB_NAME", "LIB_VERSION"]
@@ -0,0 +1,324 @@
1
+ """AzFive capture client for Python servers.
2
+
3
+ Port of ``@azfive/capture`` (see ``sdk-spec/SPEC.md``): same event envelope,
4
+ merge order, batching semantics and flag exposure rules, adapted to a server
5
+ runtime — in-memory identity, no session, background flush thread, and flag
6
+ evaluation per ``distinct_id`` through ``/v1/decide`` with a secret key.
7
+
8
+ from azfive_capture import Client
9
+
10
+ azfive = Client("azfive_…", host="https://analytics.example.com")
11
+ azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
12
+ variant = azfive.get_flag("user-42", "exp-checkout")
13
+ azfive.flush()
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import atexit
19
+ import platform
20
+ import threading
21
+ from datetime import datetime, timezone
22
+ from typing import Any
23
+
24
+ from . import transport
25
+ from .flags import FlagsManager, FlagValue
26
+
27
+ LIB_VERSION = "0.1.0"
28
+ LIB_NAME = "azfive-python"
29
+
30
+ DEFAULT_BATCH_SIZE = 10
31
+ DEFAULT_FLUSH_INTERVAL_S = 5.0
32
+ DEFAULT_MAX_QUEUE_SIZE = 1000
33
+
34
+
35
+ def _now_iso() -> str:
36
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
37
+
38
+
39
+ def _static_props() -> dict:
40
+ return {
41
+ "$os": platform.system() or "Unknown",
42
+ "$os_version": platform.release() or "",
43
+ "$device_type": "Server",
44
+ }
45
+
46
+
47
+ def merge_properties(
48
+ static: dict, session: dict, super_props: dict, event: dict, reserved: dict
49
+ ) -> dict:
50
+ """The spec'd merge order: static < session < super < event < reserved.
51
+ (Server SDKs have no dynamic layer; session is empty for them.)"""
52
+ return {**static, **session, **super_props, **event, **reserved}
53
+
54
+
55
+ class People:
56
+ """``people.set`` / ``people.set_once`` — person-property events."""
57
+
58
+ def __init__(self, client: "Client") -> None:
59
+ self._client = client
60
+
61
+ def set(self, properties: dict, distinct_id: str | None = None) -> None:
62
+ self._client._track("az.set", {}, set_props=properties, distinct_id=distinct_id)
63
+
64
+ def set_once(self, properties: dict, distinct_id: str | None = None) -> None:
65
+ self._client._track("az.set_once", {}, set_once_props=properties, distinct_id=distinct_id)
66
+
67
+
68
+ class Client:
69
+ def __init__(
70
+ self,
71
+ api_key: str,
72
+ *,
73
+ host: str,
74
+ project: str = "default",
75
+ batch_size: int = DEFAULT_BATCH_SIZE,
76
+ flush_interval_s: float = DEFAULT_FLUSH_INTERVAL_S,
77
+ max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE,
78
+ gzip: bool = False,
79
+ opt_out: bool = False,
80
+ disable_decide: bool = False,
81
+ extra_static_props: dict | None = None,
82
+ start_flush_thread: bool = True,
83
+ ) -> None:
84
+ if not api_key:
85
+ raise ValueError("api_key is required")
86
+ if not host:
87
+ raise ValueError("host is required")
88
+ self._api_key = api_key
89
+ self._host = host.rstrip("/")
90
+ self._project = project
91
+ self._batch_size = batch_size
92
+ self._flush_interval_s = flush_interval_s
93
+ self._max_queue_size = max_queue_size
94
+ self._gzip = gzip
95
+ self._opted_out = opt_out
96
+
97
+ self._distinct_id = transport.uuid4()
98
+ self._device_id = transport.uuid4()
99
+ self._identified = False
100
+ self._super_props: dict = {}
101
+ self._static = {**_static_props(), **(extra_static_props or {})}
102
+
103
+ self._queue: list[dict] = []
104
+ self._lock = threading.Lock()
105
+ self._closed = False
106
+
107
+ self.people = People(self)
108
+ self._flags: FlagsManager | None = None
109
+ if not disable_decide:
110
+ self._flags = FlagsManager(
111
+ host=self._host,
112
+ api_key=api_key,
113
+ project=project,
114
+ capture=self._capture_for_flags,
115
+ )
116
+
117
+ self._flush_event = threading.Event()
118
+ self._thread: threading.Thread | None = None
119
+ if start_flush_thread:
120
+ self._thread = threading.Thread(
121
+ target=self._flush_loop, name="azfive-capture-flush", daemon=True
122
+ )
123
+ self._thread.start()
124
+ atexit.register(self.close)
125
+
126
+ # ── Identity ──────────────────────────────────────────────────────────────
127
+
128
+ def get_distinct_id(self) -> str:
129
+ return self._distinct_id
130
+
131
+ def get_session_id(self) -> str:
132
+ return "" # servers have no session
133
+
134
+ def identify(
135
+ self, distinct_id: str, set: dict | None = None, set_once: dict | None = None
136
+ ) -> None:
137
+ did = str(distinct_id)
138
+ if self._identified and self._distinct_id == did:
139
+ if set or set_once:
140
+ self._track("az.set", {}, set_props=set, set_once_props=set_once)
141
+ return
142
+ prev = self._distinct_id
143
+ self._distinct_id = did
144
+ self._identified = True
145
+ self._track(
146
+ "az.identify",
147
+ {"$anon_distinct_id": prev},
148
+ set_props=set,
149
+ set_once_props=set_once,
150
+ distinct_id=did,
151
+ )
152
+ if self._flags:
153
+ self._flags.reload_flags(did)
154
+
155
+ def alias(self, alias: str) -> None:
156
+ self._track("az.alias", {"alias": str(alias)})
157
+
158
+ def reset(self, reset_device_id: bool = False) -> None:
159
+ self._identified = False
160
+ self._distinct_id = transport.uuid4()
161
+ self._super_props = {}
162
+ if reset_device_id:
163
+ self._device_id = transport.uuid4()
164
+ # opt-out is intentionally preserved across reset()
165
+
166
+ # ── Super properties ──────────────────────────────────────────────────────
167
+
168
+ def register(self, props: dict) -> None:
169
+ self._super_props = {**self._super_props, **props}
170
+
171
+ def register_once(self, props: dict) -> None:
172
+ self._super_props = {**props, **self._super_props} # existing values win
173
+
174
+ def unregister(self, key: str) -> None:
175
+ self._super_props.pop(key, None)
176
+
177
+ # ── Opt-out ───────────────────────────────────────────────────────────────
178
+
179
+ def opt_in_capturing(self) -> None:
180
+ self._opted_out = False
181
+
182
+ def opt_out_capturing(self) -> None:
183
+ self._opted_out = True
184
+
185
+ def has_opted_out_capturing(self) -> bool:
186
+ return self._opted_out
187
+
188
+ # ── Capture ───────────────────────────────────────────────────────────────
189
+
190
+ def capture(
191
+ self, event: str, properties: dict | None = None, *, distinct_id: str | None = None
192
+ ) -> None:
193
+ self._track(event, properties or {}, distinct_id=distinct_id)
194
+
195
+ def _capture_for_flags(
196
+ self, event: str, properties: dict, *, distinct_id: str
197
+ ) -> None:
198
+ self._track(event, properties, distinct_id=distinct_id)
199
+
200
+ def _track(
201
+ self,
202
+ event: str,
203
+ properties: dict,
204
+ *,
205
+ set_props: dict | None = None,
206
+ set_once_props: dict | None = None,
207
+ distinct_id: str | None = None,
208
+ ) -> None:
209
+ if self._opted_out:
210
+ return
211
+ props = dict(properties)
212
+ did = distinct_id or self._distinct_id
213
+ if props.get("distinct_id") is not None:
214
+ did = str(props.pop("distinct_id"))
215
+ evt: dict = {
216
+ "event": event,
217
+ "distinct_id": did,
218
+ "timestamp": _now_iso(),
219
+ "uuid": transport.uuid4(),
220
+ "properties": merge_properties(
221
+ self._static,
222
+ {},
223
+ self._super_props,
224
+ props,
225
+ {"$lib": LIB_NAME, "$lib_version": LIB_VERSION, "$device_id": self._device_id},
226
+ ),
227
+ }
228
+ if set_props:
229
+ evt["$set"] = set_props
230
+ if set_once_props:
231
+ evt["$set_once"] = set_once_props
232
+ self._enqueue(evt)
233
+
234
+ def _enqueue(self, evt: dict) -> None:
235
+ with self._lock:
236
+ self._queue.append(evt)
237
+ if len(self._queue) > self._max_queue_size:
238
+ del self._queue[: len(self._queue) - self._max_queue_size]
239
+ trigger = len(self._queue) >= self._batch_size
240
+ if trigger:
241
+ self._request_flush()
242
+
243
+ def _request_flush(self) -> None:
244
+ # With the background thread running, hand off (capture() never blocks
245
+ # on network I/O); without it (tests, single-shot scripts) flush inline.
246
+ if self._thread is not None:
247
+ self._flush_event.set()
248
+ else:
249
+ self.flush()
250
+
251
+ # ── Feature flags ─────────────────────────────────────────────────────────
252
+
253
+ def get_flag(
254
+ self, distinct_id: str, key: str, person_properties: dict | None = None
255
+ ) -> FlagValue | None:
256
+ if self._flags is None:
257
+ return None
258
+ return self._flags.get_flag(distinct_id, key, person_properties)
259
+
260
+ def is_flag_enabled(
261
+ self, distinct_id: str, key: str, person_properties: dict | None = None
262
+ ) -> bool:
263
+ if self._flags is None:
264
+ return False
265
+ return self._flags.is_flag_enabled(distinct_id, key, person_properties)
266
+
267
+ def get_flag_payload(
268
+ self, distinct_id: str, key: str, person_properties: dict | None = None
269
+ ) -> Any:
270
+ if self._flags is None:
271
+ return None
272
+ return self._flags.get_flag_payload(distinct_id, key, person_properties)
273
+
274
+ def reload_flags(self, distinct_id: str) -> None:
275
+ if self._flags is not None:
276
+ self._flags.reload_flags(distinct_id)
277
+
278
+ # ── Delivery ──────────────────────────────────────────────────────────────
279
+
280
+ def _send(self, batch: list[dict]) -> bool:
281
+ return transport.send_batch(
282
+ batch,
283
+ host=self._host,
284
+ api_key=self._api_key,
285
+ project=self._project,
286
+ gzip=self._gzip,
287
+ )
288
+
289
+ def flush(self) -> None:
290
+ with self._lock:
291
+ if not self._queue:
292
+ return
293
+ batch = self._queue
294
+ self._queue = []
295
+ if not self._send(batch):
296
+ with self._lock:
297
+ # Re-queue at the FRONT, bounded keeping the newest events.
298
+ self._queue = (batch + self._queue)[-self._max_queue_size:]
299
+
300
+ def _flush_loop(self) -> None:
301
+ while not self._closed:
302
+ self._flush_event.wait(timeout=self._flush_interval_s)
303
+ self._flush_event.clear()
304
+ if self._closed:
305
+ return
306
+ try:
307
+ self.flush()
308
+ except Exception: # never kill the flush thread
309
+ pass
310
+
311
+ def close(self) -> None:
312
+ """Flush pending events and stop the background thread. Idempotent."""
313
+ if self._closed:
314
+ return
315
+ self._closed = True
316
+ self._flush_event.set()
317
+ try:
318
+ self.flush()
319
+ except Exception:
320
+ pass
321
+
322
+
323
+ def create_client(api_key: str, **config: Any) -> Client:
324
+ return Client(api_key, **config)
@@ -0,0 +1,132 @@
1
+ """Server-side feature flags: /v1/decide per distinct_id with a TTL cache and
2
+ automatic, deduped ``az.flag_called`` exposure events.
3
+
4
+ Servers have no session, so exposures dedup on ``(distinct_id, flag, value)``
5
+ in a bounded LRU — the experiment pipeline only counts a person's FIRST
6
+ exposure anyway, so the LRU merely caps event noise, not correctness.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from collections import OrderedDict
14
+ from typing import Any, Callable
15
+
16
+ from . import transport
17
+
18
+ FlagValue = bool | str
19
+
20
+ DECIDE_CACHE_TTL_S = 60.0
21
+ DECIDE_CACHE_MAX = 10_000
22
+ EXPOSURE_LRU_MAX = 50_000
23
+
24
+
25
+ class _LRU:
26
+ """Tiny thread-safe bounded LRU used for decide caching + exposure dedup."""
27
+
28
+ def __init__(self, max_entries: int) -> None:
29
+ self._data: OrderedDict[str, Any] = OrderedDict()
30
+ self._max = max_entries
31
+ self._lock = threading.Lock()
32
+
33
+ def get(self, key: str) -> Any | None:
34
+ with self._lock:
35
+ if key not in self._data:
36
+ return None
37
+ self._data.move_to_end(key)
38
+ return self._data[key]
39
+
40
+ def put(self, key: str, value: Any) -> None:
41
+ with self._lock:
42
+ self._data[key] = value
43
+ self._data.move_to_end(key)
44
+ while len(self._data) > self._max:
45
+ self._data.popitem(last=False)
46
+
47
+ def contains(self, key: str) -> bool:
48
+ return self.get(key) is not None
49
+
50
+ def remove(self, key: str) -> None:
51
+ with self._lock:
52
+ self._data.pop(key, None)
53
+
54
+
55
+ class FlagsManager:
56
+ def __init__(
57
+ self,
58
+ *,
59
+ host: str,
60
+ api_key: str,
61
+ project: str,
62
+ capture: Callable[..., None],
63
+ cache_ttl_s: float = DECIDE_CACHE_TTL_S,
64
+ now: Callable[[], float] = time.monotonic,
65
+ ) -> None:
66
+ self._host = host
67
+ self._api_key = api_key
68
+ self._project = project
69
+ self._capture = capture
70
+ self._ttl = cache_ttl_s
71
+ self._now = now
72
+ self._decide_cache = _LRU(DECIDE_CACHE_MAX)
73
+ self._exposures = _LRU(EXPOSURE_LRU_MAX)
74
+
75
+ # ── decide ────────────────────────────────────────────────────────────────
76
+
77
+ def _decide(self, distinct_id: str, person_properties: dict | None) -> dict:
78
+ # person_properties bypass the cache only when they change the request —
79
+ # cache key folds them in so overlays don't leak between calls.
80
+ cache_key = distinct_id if not person_properties else (
81
+ distinct_id + "|" + repr(sorted(person_properties.items()))
82
+ )
83
+ cached = self._decide_cache.get(cache_key)
84
+ if cached is not None and self._now() - cached["at"] < self._ttl:
85
+ return cached["doc"]
86
+ payload: dict = {"distinct_id": distinct_id, "project": self._project}
87
+ if person_properties:
88
+ payload["person_properties"] = person_properties
89
+ doc = transport.post_json(f"{self._host}/api/v1/decide", self._api_key, payload)
90
+ if doc is None:
91
+ # Network failure: serve stale if we have it, else empty. Never raise.
92
+ return cached["doc"] if cached is not None else {"flags": {}, "flagPayloads": {}}
93
+ self._decide_cache.put(cache_key, {"at": self._now(), "doc": doc})
94
+ return doc
95
+
96
+ def reload_flags(self, distinct_id: str) -> None:
97
+ """Bust one distinct_id's cached decide response."""
98
+ self._decide_cache.remove(distinct_id)
99
+
100
+ # ── getters ───────────────────────────────────────────────────────────────
101
+
102
+ def get_flag(
103
+ self, distinct_id: str, key: str, person_properties: dict | None = None
104
+ ) -> FlagValue | None:
105
+ doc = self._decide(distinct_id, person_properties)
106
+ value = (doc.get("flags") or {}).get(key)
107
+ self._report_exposure(distinct_id, key, value)
108
+ return value
109
+
110
+ def is_flag_enabled(
111
+ self, distinct_id: str, key: str, person_properties: dict | None = None
112
+ ) -> bool:
113
+ return bool(self.get_flag(distinct_id, key, person_properties))
114
+
115
+ def get_flag_payload(
116
+ self, distinct_id: str, key: str, person_properties: dict | None = None
117
+ ) -> Any:
118
+ # No exposure — payload reads follow a get_flag/is_flag_enabled call.
119
+ doc = self._decide(distinct_id, person_properties)
120
+ return (doc.get("flagPayloads") or {}).get(key)
121
+
122
+ def _report_exposure(self, distinct_id: str, key: str, value: FlagValue | None) -> None:
123
+ reported = False if value is None else value
124
+ dedupe = f"{distinct_id}:{key}:{reported!r}"
125
+ if self._exposures.contains(dedupe):
126
+ return
127
+ self._exposures.put(dedupe, True)
128
+ self._capture(
129
+ "az.flag_called",
130
+ {"$feature_flag": key, "$feature_flag_response": reported},
131
+ distinct_id=distinct_id,
132
+ )
@@ -0,0 +1,78 @@
1
+ """HTTP transport: batch POST with optional gzip, stdlib-only.
2
+
3
+ Endpoint selection mirrors the reference SDK: public tokens (``azfive_pub_…``)
4
+ use the browser-safe ingest path, anything else the server path. Delivery is
5
+ best-effort — the client re-queues failed batches; this module never raises.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gzip as _gzip
11
+ import json
12
+ import logging
13
+ import urllib.error
14
+ import urllib.request
15
+ import uuid
16
+
17
+ logger = logging.getLogger("azfive_capture")
18
+
19
+ _TIMEOUT_S = 10.0
20
+
21
+
22
+ def uuid4() -> str:
23
+ return str(uuid.uuid4())
24
+
25
+
26
+ def ingest_path(api_key: str) -> str:
27
+ return "/api/v1/events/ingest" if api_key.startswith("azfive_pub_") else "/api/v1/events"
28
+
29
+
30
+ def send_batch(
31
+ events: list[dict],
32
+ *,
33
+ host: str,
34
+ api_key: str,
35
+ project: str,
36
+ gzip: bool = False,
37
+ ) -> bool:
38
+ """POST one batch. Returns True when the server accepted the request."""
39
+ body = json.dumps({"events": events, "project": project}).encode()
40
+ headers = {
41
+ "Authorization": f"Bearer {api_key}",
42
+ "Content-Type": "application/json",
43
+ }
44
+ if gzip:
45
+ body = _gzip.compress(body)
46
+ headers["Content-Encoding"] = "gzip"
47
+ req = urllib.request.Request(
48
+ f"{host}{ingest_path(api_key)}", data=body, headers=headers, method="POST"
49
+ )
50
+ try:
51
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as res:
52
+ return 200 <= res.status < 300
53
+ except urllib.error.HTTPError as exc:
54
+ logger.debug("azfive batch rejected: %s", exc.code)
55
+ return False
56
+ except Exception as exc: # network errors — silent, caller re-queues
57
+ logger.debug("azfive batch failed: %s", exc)
58
+ return False
59
+
60
+
61
+ def post_json(url: str, api_key: str, payload: dict) -> dict | None:
62
+ """POST JSON, return the decoded JSON response or None on any failure."""
63
+ body = json.dumps(payload).encode()
64
+ req = urllib.request.Request(
65
+ url,
66
+ data=body,
67
+ headers={
68
+ "Authorization": f"Bearer {api_key}",
69
+ "Content-Type": "application/json",
70
+ },
71
+ method="POST",
72
+ )
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=_TIMEOUT_S) as res:
75
+ return json.loads(res.read().decode())
76
+ except Exception as exc:
77
+ logger.debug("azfive decide failed: %s", exc)
78
+ return None
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: azfive-capture
3
+ Version: 0.1.0
4
+ Summary: Capture product-analytics events into AzFive from Python servers — events, identity, feature flags & experiments.
5
+ Author: AzFive
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.10
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # azfive-capture (Python)
20
+
21
+ AzFive product analytics for Python servers — events, identity, super
22
+ properties, and feature flags/experiments. Zero dependencies (stdlib only).
23
+ See `sdk-spec/SPEC.md` in the AzFive repo for the cross-SDK contract.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install azfive-capture
29
+ ```
30
+
31
+ Use a **secret** API key with the `events:write` scope (AzFive → Settings → API
32
+ Keys). Secret keys are also valid on `/v1/decide`, so server-side flag
33
+ evaluation needs no extra setup.
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ from azfive_capture import Client
39
+
40
+ azfive = Client("azfive_…", host="https://analytics.example.com", project="backend")
41
+
42
+ azfive.capture("invoice_paid", {"amount": 99}, distinct_id="user-42")
43
+ azfive.people.set({"plan": "pro"}, distinct_id="user-42")
44
+
45
+ # experiments / feature flags — get_flag() reports the exposure automatically,
46
+ # deduped per (distinct_id, flag, value)
47
+ variant = azfive.get_flag("user-42", "exp-checkout") # 'control' | 'test' | False | None
48
+ if azfive.is_flag_enabled("user-42", "new-onboarding"):
49
+ ...
50
+
51
+ azfive.flush() # also flushed by the background thread and at exit
52
+ ```
53
+
54
+ Batching: events queue in memory (batch 10 / 5 s / max 1000, oldest dropped)
55
+ and are delivered by a daemon thread; failed batches retry on the next tick.
56
+ `close()` (also registered via `atexit`) drains the queue.
57
+
58
+ ## Tests
59
+
60
+ ```bash
61
+ pip install -e '.[dev]' && pytest # golden vectors from ../sdk-spec/vectors
62
+ ```
@@ -0,0 +1,8 @@
1
+ azfive_capture/__init__.py,sha256=D2LMN2ycIoLJAkngrLfaOKQUd43wyk6ZVe0u3j7kRmk,424
2
+ azfive_capture/client.py,sha256=3Iw7aPNg4cPb2YLRpoSay1foSBPJvVcXK7-Edu7Qy34,11426
3
+ azfive_capture/flags.py,sha256=m8R_tESn-dRNz9cWSeX6_UhRbYxnukOKyUVjQzslqUM,5082
4
+ azfive_capture/transport.py,sha256=mYDNnW8rb4bm6N3C9z39V9e2zJP7hosSUKWWktCUq6E,2344
5
+ azfive_capture-0.1.0.dist-info/METADATA,sha256=cal-c_mItZiI9i3KLbm0Q43D1e_vrYtzQZdMrdug5p0,2077
6
+ azfive_capture-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
7
+ azfive_capture-0.1.0.dist-info/licenses/LICENSE,sha256=kOC_1jG3gKfLRJ7Qj404daeUxdeartrPlkwb8NsDU0Q,1063
8
+ azfive_capture-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AzFive
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.