recapi 0.4.1__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.
recapi/history.py ADDED
@@ -0,0 +1,269 @@
1
+ """Decayed user history, exactly as docs/history-spec.md (times in unix ms).
2
+
3
+ A port of ``sdk/js/src/history.js``, snapshot merging included (which the
4
+ trainer's copy doesn't need); ``spec/history_vectors.json`` keeps them equal.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import math
11
+ import re
12
+ from typing import Any
13
+
14
+ HALF_LIFE_SECONDS = 604800
15
+ TOP_K = 50
16
+ RECENT_SIZE = 20
17
+ REJECTED_SIZE = 200
18
+ MAX_ID_BYTES = 256
19
+
20
+ _RATE = math.log(2) / HALF_LIFE_SECONDS
21
+ # Go's unicode.IsControl, which the API applies to every id: C0, DEL and C1.
22
+ _CONTROL = re.compile("[\x00-\x1f\x7f-\x9f]")
23
+
24
+
25
+ def is_id(value: object) -> bool:
26
+ """Return whether the API accepts ``value`` as an id: 1-256 bytes of UTF-8, no controls."""
27
+ if not isinstance(value, str) or not value or _CONTROL.search(value):
28
+ return False
29
+ try:
30
+ return len(value.encode("utf-8")) <= MAX_ID_BYTES
31
+ except UnicodeEncodeError: # a lone surrogate
32
+ return False
33
+
34
+
35
+ def _decay(dt_ms: float) -> float:
36
+ return math.exp(-_RATE * (dt_ms / 1000))
37
+
38
+
39
+ def _is_weight(value: object) -> bool:
40
+ return _is_time(value) and value >= 0
41
+
42
+
43
+ def _is_time(value: object) -> bool:
44
+ return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
45
+
46
+
47
+ class History:
48
+ """One user's history: weighted top, recent list and rejection marks.
49
+
50
+ ``top`` maps item -> (score, t), ``recent`` is (item, t) oldest first,
51
+ ``rejected`` maps item -> time of the newest rejection, ``last_t`` is the
52
+ newest event applied to the recent list (None when unknown).
53
+ """
54
+
55
+ __slots__ = ("last_t", "recent", "rejected", "top")
56
+
57
+ def __init__(self) -> None:
58
+ self.top: dict[str, tuple[float, float]] = {}
59
+ self.recent: list[tuple[str, float]] = []
60
+ self.rejected: dict[str, float] = {}
61
+ self.last_t: float | None = None
62
+
63
+ def is_empty(self) -> bool:
64
+ """Return whether there is nothing to send, so the server uses its stored history.
65
+
66
+ Rejections alone don't count: the server still has the user's positive
67
+ events, and it excludes both sets.
68
+ """
69
+ return not self.top and not self.recent
70
+
71
+ def apply(self, item: str, level: int, t: float) -> None:
72
+ """Apply one event: level 0 changes nothing, a negative level is a rejection."""
73
+ if level == 0:
74
+ return
75
+ if level < 0:
76
+ self._reject(item, t)
77
+ return
78
+ mark = self.rejected.get(item)
79
+ if mark is not None:
80
+ if t < mark:
81
+ return
82
+ del self.rejected[item]
83
+ current = self.top.get(item)
84
+ if current is None:
85
+ self.top[item] = (float(level), t)
86
+ elif t >= current[1]:
87
+ self.top[item] = (current[0] * _decay(t - current[1]) + level, t)
88
+ else:
89
+ self.top[item] = (current[0] + level * _decay(current[1] - t), current[1])
90
+ if self.last_t is None or t >= self.last_t:
91
+ self.recent = [entry for entry in self.recent if entry[0] != item]
92
+ self.recent.append((item, t))
93
+ self.recent = self.recent[-RECENT_SIZE:]
94
+ self.last_t = t
95
+ self._prune(t)
96
+
97
+ def _reject(self, item: str, t: float) -> None:
98
+ # Dropped, not lowered: a lowered score would decay back towards 0.
99
+ current = self.top.get(item)
100
+ if current is not None and current[1] > t:
101
+ return
102
+ self.top.pop(item, None)
103
+ self.recent = [entry for entry in self.recent if entry[0] != item]
104
+ self.rejected[item] = max(t, self.rejected.get(item, t))
105
+ self._prune_rejected()
106
+
107
+ def ranked(self, now: float) -> list[tuple[str, float, float]]:
108
+ """Return (item, weight at ``now``, t) by weight desc, ties by item id asc."""
109
+ entries = [(item, s * _decay(now - t), t) for item, (s, t) in self.top.items()]
110
+ entries.sort(key=lambda e: (-e[1], e[0]))
111
+ return entries
112
+
113
+ def rejected_order(self) -> list[str]:
114
+ """Return the rejected items, oldest mark first, ties by item id."""
115
+ return sorted(self.rejected, key=lambda item: (self.rejected[item], item))
116
+
117
+ def _prune(self, now: float) -> None:
118
+ if len(self.top) > TOP_K:
119
+ self.top = {item: self.top[item] for item, _, _ in self.ranked(now)[:TOP_K]}
120
+
121
+ def _prune_rejected(self) -> None:
122
+ if len(self.rejected) > REJECTED_SIZE:
123
+ kept = self.rejected_order()[-REJECTED_SIZE:]
124
+ self.rejected = {item: self.rejected[item] for item in kept}
125
+
126
+ def wire(self, now: float) -> tuple[list[list[Any]], list[str], list[str]]:
127
+ """Return the request's ``history`` and ``recent``, and the rejected items.
128
+
129
+ Weights are decayed to ``now`` and rounded half up to 4 decimals like
130
+ JS ``Math.round`` (Python's ``round`` rounds half to even); ages are
131
+ whole seconds, never negative.
132
+ """
133
+ history = [
134
+ [item, math.floor(weight * 1e4 + 0.5) / 1e4, max(0, math.floor((now - t) / 1000))]
135
+ for item, weight, t in self.ranked(now)[:TOP_K]
136
+ ]
137
+ recent = [item for item, _ in self.recent[-RECENT_SIZE:]]
138
+ return history, recent, self.rejected_order()
139
+
140
+ @classmethod
141
+ def from_snapshot(cls, snapshot: object) -> History:
142
+ """Build a history from the server's snapshot, skipping invalid entries.
143
+
144
+ Each top entry is a score at ``as_of``, each rejected item a mark at
145
+ ``as_of``. ``last_t`` stays unknown: ``as_of`` is the server clock, and a
146
+ local clock behind it would see every new event as out of order.
147
+ """
148
+ state = cls()
149
+ if not isinstance(snapshot, dict) or not _is_time(snapshot.get("as_of")):
150
+ return state
151
+ as_of = snapshot["as_of"]
152
+ for e in _list(snapshot.get("top")):
153
+ if isinstance(e, list) and len(e) >= 2 and is_id(e[0]) and _is_weight(e[1]):
154
+ state.top[e[0]] = (float(e[1]), as_of)
155
+ seen: set[str] = set()
156
+ for item in _list(snapshot.get("recent")):
157
+ if is_id(item) and item not in seen:
158
+ seen.add(item)
159
+ state.recent.append((item, as_of))
160
+ state.recent = state.recent[-RECENT_SIZE:]
161
+ for item in _list(snapshot.get("rejected")):
162
+ if is_id(item):
163
+ state.rejected[item] = as_of
164
+ state._prune(as_of)
165
+ state._prune_rejected()
166
+ return state
167
+
168
+ def merge(self, other: History, now: float) -> History:
169
+ """Return the union of two histories built from disjoint events.
170
+
171
+ Top scores add up, expressed at the later of the two times; the recent
172
+ lists are interleaved by time (``self`` first on ties); rejection marks
173
+ are united, and a mark older than the item's merged entry is dropped,
174
+ otherwise the item leaves the top and the recent list.
175
+ """
176
+ out = History()
177
+ out.top = dict(self.top)
178
+ for item, (s, t) in other.top.items():
179
+ mine = out.top.get(item)
180
+ if mine is None:
181
+ out.top[item] = (s, t)
182
+ continue
183
+ later = max(t, mine[1])
184
+ out.top[item] = (s * _decay(later - t) + mine[0] * _decay(later - mine[1]), later)
185
+
186
+ out.rejected = dict(self.rejected)
187
+ for item, t in other.rejected.items():
188
+ out.rejected[item] = max(t, out.rejected.get(item, t))
189
+ for item, t in list(out.rejected.items()):
190
+ entry = out.top.get(item)
191
+ if entry is not None and entry[1] > t:
192
+ del out.rejected[item]
193
+ else:
194
+ out.top.pop(item, None)
195
+
196
+ by_item: dict[str, tuple[float, int]] = {}
197
+ for order, (item, t) in enumerate([*self.recent, *other.recent]):
198
+ existing = by_item.get(item)
199
+ if existing is None or t > existing[0]:
200
+ by_item[item] = (t, order)
201
+ # Rejected items go before the trim, so they don't cost the list its places.
202
+ kept = sorted(
203
+ ((t, order, item) for item, (t, order) in by_item.items() if item not in out.rejected)
204
+ )
205
+ out.recent = [(item, t) for t, _, item in kept[-RECENT_SIZE:]]
206
+
207
+ times = [t for t in (self.last_t, other.last_t) if t is not None]
208
+ out.last_t = max(times) if times else None
209
+ out._prune(now)
210
+ out._prune_rejected()
211
+ return out
212
+
213
+ def merge_snapshot(self, snapshot: History, now: float) -> History:
214
+ """Merge a server snapshot, keeping the items rejected here rejected.
215
+
216
+ The snapshot dates its scores ``as_of``, which only bounds when its
217
+ events happened; a local rejection is usually one the server hasn't
218
+ stored yet, so it wins over the snapshot's score.
219
+ """
220
+ trimmed = copy.copy(snapshot)
221
+ trimmed.top = {k: v for k, v in snapshot.top.items() if k not in self.rejected}
222
+ return self.merge(trimmed, now)
223
+
224
+ def encode(self) -> dict[str, Any]:
225
+ """Return the JSON-safe stored form (raw scores), shaped like JS ``encodeState``."""
226
+ return {
227
+ "top": [[item, s, t] for item, (s, t) in self.top.items()],
228
+ "recent": [[item, t] for item, t in self.recent],
229
+ "rejected": [[item, t] for item, t in self.rejected.items()],
230
+ "lastT": self.last_t,
231
+ }
232
+
233
+ @classmethod
234
+ def decode(cls, raw: object) -> History | None:
235
+ """Inverse of :meth:`encode`; None for any malformed field.
236
+
237
+ A state stored before rejections existed has no ``rejected``.
238
+ """
239
+ if not _valid_encoding(raw):
240
+ return None
241
+ assert isinstance(raw, dict)
242
+ state = cls()
243
+ state.top = {item: (float(s), t) for item, s, t in raw["top"]}
244
+ state.recent = [(item, t) for item, t in raw["recent"]]
245
+ state.rejected = {item: t for item, t in raw.get("rejected", [])}
246
+ state.last_t = raw["lastT"]
247
+ state._prune_rejected()
248
+ return state
249
+
250
+
251
+ def _valid_encoding(raw: object) -> bool:
252
+ if not isinstance(raw, dict) or "lastT" not in raw:
253
+ return False
254
+ top, recent, rejected = raw.get("top"), raw.get("recent"), raw.get("rejected", [])
255
+ if not all(isinstance(v, list) for v in (top, recent, rejected)):
256
+ return False
257
+ return (
258
+ (raw["lastT"] is None or _is_time(raw["lastT"]))
259
+ and all(_pair(e, 3) and _is_weight(e[1]) and _is_time(e[2]) for e in top)
260
+ and all(_pair(e, 2) and _is_time(e[1]) for e in [*recent, *rejected])
261
+ )
262
+
263
+
264
+ def _list(value: object) -> list[Any]:
265
+ return value if isinstance(value, list) else []
266
+
267
+
268
+ def _pair(entry: object, size: int) -> bool:
269
+ return isinstance(entry, list) and len(entry) == size and is_id(entry[0])
@@ -0,0 +1,212 @@
1
+ Metadata-Version: 2.4
2
+ Name: recapi
3
+ Version: 0.4.1
4
+ Summary: Python client for the recapi recommendation API: per-user history, event batching, retries, popular fallback.
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Dynamic: license-file
10
+
11
+ # recapi for Python
12
+
13
+ Python client for the recapi recommendation API, for servers: Django,
14
+ Flask, FastAPI, workers and scripts. Python ≥ 3.11, standard library only:
15
+ installing it never adds a dependency to your application.
16
+
17
+ It does what the JS SDK does in a browser, for every user of your server: it
18
+ keeps each user's **history** and sends it with every recommendation request
19
+ (recommendations follow the latest click, and recapi doesn't look the user
20
+ up), **batches events** in the background (up to 1,000 per request, an
21
+ idempotency key each, retries with backoff), **falls back to popular items**
22
+ when recapi is slow or failing, and **attributes** events on recommended items
23
+ to the recommendation that showed them (the dashboard's clicks and conversions).
24
+
25
+ ## Install
26
+
27
+ Your recapi API serves the library: install it with pip, or put the same URL
28
+ in your `requirements.txt`. No package index, no build step, no dependencies:
29
+
30
+ ```sh
31
+ pip install https://api.<your-domain>/sdk/python/recapi-0.4.1-py3-none-any.whl
32
+ ```
33
+
34
+ The dashboard's quick start shows this line with your API's address. To work
35
+ on the library itself, install it from a checkout of this repository
36
+ (`pip install ./sdk/python`).
37
+
38
+ ## Quick start
39
+
40
+ ```python
41
+ import recapi
42
+
43
+ # One client per process, created at start-up. Use a secret key: it stays on
44
+ # your server.
45
+ client = recapi.Client("rk_sec_…", "https://api.example.com")
46
+
47
+ # Describe your items (a product, an article, a video…), 1,000 per request.
48
+ # metadata is yours (up to 2 KB) and comes back with the item in every list.
49
+ item = {"item_id": "item-42", "tags": ["sports"], "title": "Cup final highlights"}
50
+ item["metadata"] = {"name": "Cup final highlights", "url": "/videos/42"}
51
+ client.upsert_items([item])
52
+
53
+ # The user opened an item (level 3). Queued and sent in the background.
54
+ client.track("item-42", "view", user_id="user-881")
55
+
56
+ # Recommendations for that user. If recapi is slow or failing, this returns
57
+ # popular items instead (result.fallback is True), and an empty list if
58
+ # recapi is down altogether (hide the slot): no try/except.
59
+ # item.why says why it is listed, safe to show anyone (docs/api.md, "Reason
60
+ # codes"): pick your own words, no label for codes you don't map.
61
+ LABELS = {
62
+ "for_you": "Picked for you",
63
+ "similar": "Similar items",
64
+ "next": "Up next",
65
+ "bought_together": "Often bought together",
66
+ "trending": "Trending now",
67
+ "popular": "Popular",
68
+ "new": "New",
69
+ }
70
+ result = client.recommend(user_id="user-881", placement="homepage", count=4)
71
+ for item in result.items:
72
+ print(item.item_id, item.slot, item.metadata) # metadata: a dict, or None
73
+ label = LABELS.get(item.why) # "Trending now"..., None: show no label
74
+
75
+ # The user clicks a recommended item: track it as usual. The event carries
76
+ # the recommendation's id by itself (within 30 minutes of the display, then
77
+ # for 24 hours after the click).
78
+ client.track("item-77", "view", user_id="user-881")
79
+ ```
80
+
81
+ Levels follow the [recommended scale](../../docs/api.md#interaction-levels):
82
+ `"view"` (3), `"cart"` (5), `"purchase"` (10), or any integer from -10 to 10
83
+ (`track("article-9", 5, …)` for an article read to the end); 0 is an
84
+ impression. `"reject"` (-10) is an explicit "not interested": the item is never
85
+ recommended to that user again until a positive event on it. Visitors who are
86
+ not logged in: pass a stable `anon_id` instead of `user_id`, a random id kept
87
+ in their session or a cookie (`secrets.token_hex(16)`), never the session id
88
+ itself: recapi stores the `anon_id` and the dashboard shows it. Call
89
+ `client.identify(anon_id, user_id)` when they log in: their history and the
90
+ items they were shown become the user's, so what they did before logging in
91
+ keeps counting.
92
+
93
+ ## Where the history lives
94
+
95
+ By default each process keeps its users' state in memory (the 10,000 most
96
+ recently seen users). That is enough for one process. When several
97
+ processes or servers serve the same users, give them a shared store: any
98
+ object with `get(key)`, `set(key, value)` (strings) and `delete(key)` works,
99
+ such as a Redis client or Django's cache.
100
+
101
+ ```python
102
+ client = recapi.Client("rk_sec_…", "https://api.example.com", store=redis.Redis())
103
+ # or, in Django (from django.core.cache import cache):
104
+ client = recapi.Client("rk_sec_…", "https://api.example.com", store=cache)
105
+ ```
106
+
107
+ A user the store doesn't know yet (a new process, an evicted user, another
108
+ server) still gets personalised results: the request then has no history,
109
+ recapi uses the history it keeps from that user's events, and returns it with
110
+ the recommendations; the client stores it for the next calls. If the store's
111
+ first call for that user was a `track()` or an `identify()`, the first
112
+ `recommend()` fetches that history (`GET /v1/users/{id}/history`, unbilled)
113
+ with half of its 1.5 s and merges it with what was tracked here; a failed
114
+ fetch is tried again on the next call. This works for `user_id` only, and
115
+ only with a secret key. A store that fails
116
+ (Redis down) is logged and skipped: tracking and recommendations go on.
117
+ Updates to one user are serialised within a process only: two processes
118
+ updating the same user in the same instant can lose one of the two changes to
119
+ the stored history (never the event itself, which recapi still receives).
120
+
121
+ Each user's state is a small JSON value (at most 50 weighted items, 20
122
+ recent, 200 rejections and 100 shown items) under
123
+ `recapi:<hash of the API key>:u:<user_id>` (`:a:<anon_id>` for visitors), so a
124
+ test and a production key never share entries.
125
+
126
+ ## Processes and shutdown
127
+
128
+ Create the client once per process; it is thread-safe, and a forked child
129
+ (gunicorn, uWSGI) starts with an empty queue of its own. Queued events are
130
+ sent at interpreter exit (one try, so a deploy never waits on an outage).
131
+ Short-lived jobs and serverless handlers call `client.flush()` before they
132
+ return: it sends everything now, with retries.
133
+
134
+ The client keeps its connections to recapi open between calls (one per call
135
+ in flight at the busiest moment, up to 16), so a call pays the TLS handshake
136
+ once, not every time (at 10 ms from the API, a `recommend` takes 13 ms instead
137
+ of 43). A connection the API closed while idle is replaced on the next call.
138
+ `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` are honoured. Redirects are not
139
+ followed: a 3xx fails like a 4xx, so `base_url` must be the API's own address
140
+ (`https://` in production).
141
+
142
+ ## asyncio (FastAPI, async Django)
143
+
144
+ In an `async def` handler, a blocking call would stop every other request of
145
+ the worker while it waits. `recapi.AsyncClient` takes the same arguments as
146
+ `Client` and has the same methods, as coroutines. Its calls run on a few
147
+ threads of its own, and `recommend`'s 1.5 s count from the `await`: a call
148
+ that finds no free thread by then returns popular items.
149
+
150
+ ```python
151
+ import recapi
152
+ from fastapi import FastAPI
153
+
154
+ client = recapi.AsyncClient("rk_sec_…", "https://api.example.com")
155
+ app = FastAPI()
156
+
157
+
158
+ @app.get("/home")
159
+ async def home(user_id: str):
160
+ result = await client.recommend(user_id=user_id, placement="homepage", count=4)
161
+ return [item.item_id for item in result.items]
162
+ ```
163
+
164
+ In plain `def` handlers and sync Django views, keep `Client`.
165
+
166
+ ## Page caches
167
+
168
+ A page cache (Django's `cache_page` or per-site cache middleware, Varnish, a
169
+ CDN) stores a page's HTML and serves it to every visitor. A list
170
+ `recommend()` rendered into that HTML is then one visitor's list, under one
171
+ recommendation id, shown to everyone: no personalisation, no new items
172
+ tried, and clicks and experiments counted against that one list. Render the
173
+ recommendations in the browser with the JS library (the script tag in the
174
+ dashboard's quick start), or from a fragment the page cache leaves out: an
175
+ uncached view called by AJAX that runs `recommend()`. Never cache a
176
+ `recommend()` result across visitors. The dashboard's Overview warns when
177
+ one recommendation id collects the events of many users ("Page caches").
178
+
179
+ ## Reference
180
+
181
+ | Method | What it does |
182
+ |---|---|
183
+ | `track(item_id, level, *, user_id=None, anon_id=None, recommendation_id=None)` | Updates the user's history at once and queues the event. `recommendation_id` overrides the automatic attribution. Raises `ValueError` for an invalid id or level |
184
+ | `recommend(*, user_id=None, anon_id=None, count=0, placement=None, tags_any=None, tags_all=None, exclude=None, context_item_id=None, discovery=None)` | Returns `Recommendations(recommendation_id, fallback, items)`, each item `Item(item_id, score, slot, metadata, why)`. `metadata` is the dict stored with the item ([Item metadata](../../docs/api.md#item-metadata)), None when it has none; `why` its [reason code](../../docs/api.md#reason-codes). The user's rejected items are added to `exclude` |
185
+ | `popular(*, count=10, tags_any=None, tags_all=None)` | Popular items (`Item`, metadata and `why` included), for pages without a user |
186
+ | `upsert_items(items)` | Creates or replaces items (dicts with the [item fields](../../docs/api.md#put-v1itemsitem_id--secret-key)), 1,000 per request, with retries. Returns how many were stored |
187
+ | `identify(anon_id, user_id)` | At login: merges the visitor's history and shown items into the user's, then deletes the visitor's entry |
188
+ | `delete_user(user_id)` | Erases the user's events in recapi (GDPR) and their state in the store |
189
+ | `flush()` / `close()` | Send queued events now; `close()` also stops the background sender |
190
+
191
+ `Client(api_key, base_url, *, store=None, timeout=5.0, flush_interval=2.0,
192
+ max_batch=1000)`: `timeout` per request in seconds (`recommend` waits at most
193
+ 1.5 s). `AsyncClient` takes the same arguments. Errors are `recapi.RecapiError` (`code`, `status`, `retryable`);
194
+ `recommend` raises only on a 3xx, 4xx or 429, or a reply that isn't the API's
195
+ (`invalid_response`, such as a `base_url` pointing at a web page): a fallback
196
+ would hide them. On a
197
+ network error, a timeout or a 5xx it returns popular items, or an empty list
198
+ when `/v1/popular` fails too, and logs the errors.
199
+ Background failures go to the `recapi` logger: refused events are dropped, a
200
+ batch that can succeed later is retried with backoff (up to 10,000 queued).
201
+
202
+ The API builds the wheel from `recapi/*.py` and `pyproject.toml`, and pip
203
+ caches its URL for a year: a change there needs a new `version` in
204
+ `pyproject.toml` and `__version__` in `recapi/client.py`, and a new entry in
205
+ `published.json` (the Go test `TestWheelChangesNeedANewVersion` prints it).
206
+ An older version's URL then installs the new one, so the library's public
207
+ methods and their arguments only grow.
208
+
209
+ Tests: `cd sdk/python && python3 -m unittest discover -s tests`. They run the
210
+ shared vectors of the [history spec](../../docs/history-spec.md) and of the
211
+ behaviour every library shares (`spec/client_vectors.json`) against a local
212
+ HTTP server.
@@ -0,0 +1,9 @@
1
+ recapi/__init__.py,sha256=dBZomX78DpCBWvfyqQ1AZu1qeLWuDIdeHyNCowqMjG4,1023
2
+ recapi/async_client.py,sha256=4Ih_busG8XNFlfePsZNnksTGkihLGGX_nzF-zUTr3lY,4040
3
+ recapi/client.py,sha256=N-Yr06fcMgr7rGVn4tzzXIuQXCsYZDB0_jmR_LuRxWA,41339
4
+ recapi/history.py,sha256=G_IDP8hTZYatx1n_wTLUG9Xvxum8xcfP44RTDCb81O4,10717
5
+ recapi-0.4.1.dist-info/licenses/LICENSE,sha256=XykRbxi-optoQKGLtp2z4VTCzFHPzAqWSrnnwo2wIgA,1063
6
+ recapi-0.4.1.dist-info/METADATA,sha256=1TUCLErII3a_OxSGprQJXDTTEI8V8QVsxhBgVeToFY0,11025
7
+ recapi-0.4.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ recapi-0.4.1.dist-info/top_level.txt,sha256=kG0cd06DOh-xlw4tYZWttzEO4zjkDIlMvrw7XnuWsg8,7
9
+ recapi-0.4.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 recapi
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.
@@ -0,0 +1 @@
1
+ recapi