recapi 0.4.1__tar.gz
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-0.4.1/LICENSE +21 -0
- recapi-0.4.1/PKG-INFO +212 -0
- recapi-0.4.1/README.md +202 -0
- recapi-0.4.1/pyproject.toml +34 -0
- recapi-0.4.1/recapi/__init__.py +38 -0
- recapi-0.4.1/recapi/async_client.py +95 -0
- recapi-0.4.1/recapi/client.py +1018 -0
- recapi-0.4.1/recapi/history.py +269 -0
- recapi-0.4.1/recapi.egg-info/PKG-INFO +212 -0
- recapi-0.4.1/recapi.egg-info/SOURCES.txt +15 -0
- recapi-0.4.1/recapi.egg-info/dependency_links.txt +1 -0
- recapi-0.4.1/recapi.egg-info/top_level.txt +1 -0
- recapi-0.4.1/setup.cfg +4 -0
- recapi-0.4.1/tests/test_async_client.py +185 -0
- recapi-0.4.1/tests/test_client.py +1017 -0
- recapi-0.4.1/tests/test_client_vectors.py +277 -0
- recapi-0.4.1/tests/test_history.py +131 -0
recapi-0.4.1/LICENSE
ADDED
|
@@ -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.
|
recapi-0.4.1/PKG-INFO
ADDED
|
@@ -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.
|
recapi-0.4.1/README.md
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# recapi for Python
|
|
2
|
+
|
|
3
|
+
Python client for the recapi recommendation API, for servers: Django,
|
|
4
|
+
Flask, FastAPI, workers and scripts. Python ≥ 3.11, standard library only:
|
|
5
|
+
installing it never adds a dependency to your application.
|
|
6
|
+
|
|
7
|
+
It does what the JS SDK does in a browser, for every user of your server: it
|
|
8
|
+
keeps each user's **history** and sends it with every recommendation request
|
|
9
|
+
(recommendations follow the latest click, and recapi doesn't look the user
|
|
10
|
+
up), **batches events** in the background (up to 1,000 per request, an
|
|
11
|
+
idempotency key each, retries with backoff), **falls back to popular items**
|
|
12
|
+
when recapi is slow or failing, and **attributes** events on recommended items
|
|
13
|
+
to the recommendation that showed them (the dashboard's clicks and conversions).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
Your recapi API serves the library: install it with pip, or put the same URL
|
|
18
|
+
in your `requirements.txt`. No package index, no build step, no dependencies:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install https://api.<your-domain>/sdk/python/recapi-0.4.1-py3-none-any.whl
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The dashboard's quick start shows this line with your API's address. To work
|
|
25
|
+
on the library itself, install it from a checkout of this repository
|
|
26
|
+
(`pip install ./sdk/python`).
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import recapi
|
|
32
|
+
|
|
33
|
+
# One client per process, created at start-up. Use a secret key: it stays on
|
|
34
|
+
# your server.
|
|
35
|
+
client = recapi.Client("rk_sec_…", "https://api.example.com")
|
|
36
|
+
|
|
37
|
+
# Describe your items (a product, an article, a video…), 1,000 per request.
|
|
38
|
+
# metadata is yours (up to 2 KB) and comes back with the item in every list.
|
|
39
|
+
item = {"item_id": "item-42", "tags": ["sports"], "title": "Cup final highlights"}
|
|
40
|
+
item["metadata"] = {"name": "Cup final highlights", "url": "/videos/42"}
|
|
41
|
+
client.upsert_items([item])
|
|
42
|
+
|
|
43
|
+
# The user opened an item (level 3). Queued and sent in the background.
|
|
44
|
+
client.track("item-42", "view", user_id="user-881")
|
|
45
|
+
|
|
46
|
+
# Recommendations for that user. If recapi is slow or failing, this returns
|
|
47
|
+
# popular items instead (result.fallback is True), and an empty list if
|
|
48
|
+
# recapi is down altogether (hide the slot): no try/except.
|
|
49
|
+
# item.why says why it is listed, safe to show anyone (docs/api.md, "Reason
|
|
50
|
+
# codes"): pick your own words, no label for codes you don't map.
|
|
51
|
+
LABELS = {
|
|
52
|
+
"for_you": "Picked for you",
|
|
53
|
+
"similar": "Similar items",
|
|
54
|
+
"next": "Up next",
|
|
55
|
+
"bought_together": "Often bought together",
|
|
56
|
+
"trending": "Trending now",
|
|
57
|
+
"popular": "Popular",
|
|
58
|
+
"new": "New",
|
|
59
|
+
}
|
|
60
|
+
result = client.recommend(user_id="user-881", placement="homepage", count=4)
|
|
61
|
+
for item in result.items:
|
|
62
|
+
print(item.item_id, item.slot, item.metadata) # metadata: a dict, or None
|
|
63
|
+
label = LABELS.get(item.why) # "Trending now"..., None: show no label
|
|
64
|
+
|
|
65
|
+
# The user clicks a recommended item: track it as usual. The event carries
|
|
66
|
+
# the recommendation's id by itself (within 30 minutes of the display, then
|
|
67
|
+
# for 24 hours after the click).
|
|
68
|
+
client.track("item-77", "view", user_id="user-881")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Levels follow the [recommended scale](../../docs/api.md#interaction-levels):
|
|
72
|
+
`"view"` (3), `"cart"` (5), `"purchase"` (10), or any integer from -10 to 10
|
|
73
|
+
(`track("article-9", 5, …)` for an article read to the end); 0 is an
|
|
74
|
+
impression. `"reject"` (-10) is an explicit "not interested": the item is never
|
|
75
|
+
recommended to that user again until a positive event on it. Visitors who are
|
|
76
|
+
not logged in: pass a stable `anon_id` instead of `user_id`, a random id kept
|
|
77
|
+
in their session or a cookie (`secrets.token_hex(16)`), never the session id
|
|
78
|
+
itself: recapi stores the `anon_id` and the dashboard shows it. Call
|
|
79
|
+
`client.identify(anon_id, user_id)` when they log in: their history and the
|
|
80
|
+
items they were shown become the user's, so what they did before logging in
|
|
81
|
+
keeps counting.
|
|
82
|
+
|
|
83
|
+
## Where the history lives
|
|
84
|
+
|
|
85
|
+
By default each process keeps its users' state in memory (the 10,000 most
|
|
86
|
+
recently seen users). That is enough for one process. When several
|
|
87
|
+
processes or servers serve the same users, give them a shared store: any
|
|
88
|
+
object with `get(key)`, `set(key, value)` (strings) and `delete(key)` works,
|
|
89
|
+
such as a Redis client or Django's cache.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
client = recapi.Client("rk_sec_…", "https://api.example.com", store=redis.Redis())
|
|
93
|
+
# or, in Django (from django.core.cache import cache):
|
|
94
|
+
client = recapi.Client("rk_sec_…", "https://api.example.com", store=cache)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A user the store doesn't know yet (a new process, an evicted user, another
|
|
98
|
+
server) still gets personalised results: the request then has no history,
|
|
99
|
+
recapi uses the history it keeps from that user's events, and returns it with
|
|
100
|
+
the recommendations; the client stores it for the next calls. If the store's
|
|
101
|
+
first call for that user was a `track()` or an `identify()`, the first
|
|
102
|
+
`recommend()` fetches that history (`GET /v1/users/{id}/history`, unbilled)
|
|
103
|
+
with half of its 1.5 s and merges it with what was tracked here; a failed
|
|
104
|
+
fetch is tried again on the next call. This works for `user_id` only, and
|
|
105
|
+
only with a secret key. A store that fails
|
|
106
|
+
(Redis down) is logged and skipped: tracking and recommendations go on.
|
|
107
|
+
Updates to one user are serialised within a process only: two processes
|
|
108
|
+
updating the same user in the same instant can lose one of the two changes to
|
|
109
|
+
the stored history (never the event itself, which recapi still receives).
|
|
110
|
+
|
|
111
|
+
Each user's state is a small JSON value (at most 50 weighted items, 20
|
|
112
|
+
recent, 200 rejections and 100 shown items) under
|
|
113
|
+
`recapi:<hash of the API key>:u:<user_id>` (`:a:<anon_id>` for visitors), so a
|
|
114
|
+
test and a production key never share entries.
|
|
115
|
+
|
|
116
|
+
## Processes and shutdown
|
|
117
|
+
|
|
118
|
+
Create the client once per process; it is thread-safe, and a forked child
|
|
119
|
+
(gunicorn, uWSGI) starts with an empty queue of its own. Queued events are
|
|
120
|
+
sent at interpreter exit (one try, so a deploy never waits on an outage).
|
|
121
|
+
Short-lived jobs and serverless handlers call `client.flush()` before they
|
|
122
|
+
return: it sends everything now, with retries.
|
|
123
|
+
|
|
124
|
+
The client keeps its connections to recapi open between calls (one per call
|
|
125
|
+
in flight at the busiest moment, up to 16), so a call pays the TLS handshake
|
|
126
|
+
once, not every time (at 10 ms from the API, a `recommend` takes 13 ms instead
|
|
127
|
+
of 43). A connection the API closed while idle is replaced on the next call.
|
|
128
|
+
`HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` are honoured. Redirects are not
|
|
129
|
+
followed: a 3xx fails like a 4xx, so `base_url` must be the API's own address
|
|
130
|
+
(`https://` in production).
|
|
131
|
+
|
|
132
|
+
## asyncio (FastAPI, async Django)
|
|
133
|
+
|
|
134
|
+
In an `async def` handler, a blocking call would stop every other request of
|
|
135
|
+
the worker while it waits. `recapi.AsyncClient` takes the same arguments as
|
|
136
|
+
`Client` and has the same methods, as coroutines. Its calls run on a few
|
|
137
|
+
threads of its own, and `recommend`'s 1.5 s count from the `await`: a call
|
|
138
|
+
that finds no free thread by then returns popular items.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
import recapi
|
|
142
|
+
from fastapi import FastAPI
|
|
143
|
+
|
|
144
|
+
client = recapi.AsyncClient("rk_sec_…", "https://api.example.com")
|
|
145
|
+
app = FastAPI()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.get("/home")
|
|
149
|
+
async def home(user_id: str):
|
|
150
|
+
result = await client.recommend(user_id=user_id, placement="homepage", count=4)
|
|
151
|
+
return [item.item_id for item in result.items]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
In plain `def` handlers and sync Django views, keep `Client`.
|
|
155
|
+
|
|
156
|
+
## Page caches
|
|
157
|
+
|
|
158
|
+
A page cache (Django's `cache_page` or per-site cache middleware, Varnish, a
|
|
159
|
+
CDN) stores a page's HTML and serves it to every visitor. A list
|
|
160
|
+
`recommend()` rendered into that HTML is then one visitor's list, under one
|
|
161
|
+
recommendation id, shown to everyone: no personalisation, no new items
|
|
162
|
+
tried, and clicks and experiments counted against that one list. Render the
|
|
163
|
+
recommendations in the browser with the JS library (the script tag in the
|
|
164
|
+
dashboard's quick start), or from a fragment the page cache leaves out: an
|
|
165
|
+
uncached view called by AJAX that runs `recommend()`. Never cache a
|
|
166
|
+
`recommend()` result across visitors. The dashboard's Overview warns when
|
|
167
|
+
one recommendation id collects the events of many users ("Page caches").
|
|
168
|
+
|
|
169
|
+
## Reference
|
|
170
|
+
|
|
171
|
+
| Method | What it does |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `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 |
|
|
174
|
+
| `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` |
|
|
175
|
+
| `popular(*, count=10, tags_any=None, tags_all=None)` | Popular items (`Item`, metadata and `why` included), for pages without a user |
|
|
176
|
+
| `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 |
|
|
177
|
+
| `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 |
|
|
178
|
+
| `delete_user(user_id)` | Erases the user's events in recapi (GDPR) and their state in the store |
|
|
179
|
+
| `flush()` / `close()` | Send queued events now; `close()` also stops the background sender |
|
|
180
|
+
|
|
181
|
+
`Client(api_key, base_url, *, store=None, timeout=5.0, flush_interval=2.0,
|
|
182
|
+
max_batch=1000)`: `timeout` per request in seconds (`recommend` waits at most
|
|
183
|
+
1.5 s). `AsyncClient` takes the same arguments. Errors are `recapi.RecapiError` (`code`, `status`, `retryable`);
|
|
184
|
+
`recommend` raises only on a 3xx, 4xx or 429, or a reply that isn't the API's
|
|
185
|
+
(`invalid_response`, such as a `base_url` pointing at a web page): a fallback
|
|
186
|
+
would hide them. On a
|
|
187
|
+
network error, a timeout or a 5xx it returns popular items, or an empty list
|
|
188
|
+
when `/v1/popular` fails too, and logs the errors.
|
|
189
|
+
Background failures go to the `recapi` logger: refused events are dropped, a
|
|
190
|
+
batch that can succeed later is retried with backoff (up to 10,000 queued).
|
|
191
|
+
|
|
192
|
+
The API builds the wheel from `recapi/*.py` and `pyproject.toml`, and pip
|
|
193
|
+
caches its URL for a year: a change there needs a new `version` in
|
|
194
|
+
`pyproject.toml` and `__version__` in `recapi/client.py`, and a new entry in
|
|
195
|
+
`published.json` (the Go test `TestWheelChangesNeedANewVersion` prints it).
|
|
196
|
+
An older version's URL then installs the new one, so the library's public
|
|
197
|
+
methods and their arguments only grow.
|
|
198
|
+
|
|
199
|
+
Tests: `cd sdk/python && python3 -m unittest discover -s tests`. They run the
|
|
200
|
+
shared vectors of the [history spec](../../docs/history-spec.md) and of the
|
|
201
|
+
behaviour every library shares (`spec/client_vectors.json`) against a local
|
|
202
|
+
HTTP server.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "recapi"
|
|
7
|
+
version = "0.4.1"
|
|
8
|
+
description = "Python client for the recapi recommendation API: per-user history, event batching, retries, popular fallback."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = []
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
|
|
14
|
+
[tool.setuptools]
|
|
15
|
+
packages = ["recapi"]
|
|
16
|
+
|
|
17
|
+
[tool.ruff]
|
|
18
|
+
line-length = 100
|
|
19
|
+
target-version = "py311"
|
|
20
|
+
|
|
21
|
+
[tool.ruff.lint]
|
|
22
|
+
select = ["E", "F", "W", "I", "B", "UP", "N", "SIM", "RUF", "PL", "D"]
|
|
23
|
+
# Magic methods and __init__ are documented on the class; recommend() mirrors
|
|
24
|
+
# the API's request fields as keywords; small thresholds read better inline.
|
|
25
|
+
ignore = ["D105", "D107", "PLR0913", "PLR2004"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff.lint.pydocstyle]
|
|
28
|
+
convention = "google"
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint.per-file-ignores]
|
|
31
|
+
"tests/*" = ["D", "PLR2004", "N806", "PLC0415"]
|
|
32
|
+
|
|
33
|
+
[tool.ruff.lint.isort]
|
|
34
|
+
known-first-party = ["recapi", "fakeapi"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""recapi: recommendations for any site or app with a catalog.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import recapi
|
|
6
|
+
|
|
7
|
+
client = recapi.Client("rk_sec_...", "https://api.example.com")
|
|
8
|
+
client.track("item-42", "view", user_id="user-1")
|
|
9
|
+
recs = client.recommend(user_id="user-1", count=4)
|
|
10
|
+
|
|
11
|
+
In asyncio apps (FastAPI), ``recapi.AsyncClient`` has the same methods as
|
|
12
|
+
coroutines. See ``sdk/python/README.md``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .client import Client, Item, MemoryStore, RecapiError, Recommendations, Store, __version__
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"AsyncClient",
|
|
21
|
+
"Client",
|
|
22
|
+
"Item",
|
|
23
|
+
"MemoryStore",
|
|
24
|
+
"RecapiError",
|
|
25
|
+
"Recommendations",
|
|
26
|
+
"Store",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def __getattr__(name: str) -> Any:
|
|
32
|
+
# asyncio takes longer to import than the rest of the library: only the
|
|
33
|
+
# apps that use AsyncClient pay for it.
|
|
34
|
+
if name == "AsyncClient":
|
|
35
|
+
from .async_client import AsyncClient # noqa: PLC0415
|
|
36
|
+
|
|
37
|
+
return AsyncClient
|
|
38
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The recapi client for asyncio apps (FastAPI, async Django), standard library only.
|
|
2
|
+
|
|
3
|
+
Every method of :class:`Client` runs on a small thread pool of the client's
|
|
4
|
+
own, so a slow recapi never blocks the event loop, and the client's
|
|
5
|
+
connections to recapi stay open between calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import functools
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import weakref
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .client import Client, Item, Recommendations, _call
|
|
19
|
+
|
|
20
|
+
# Calls in flight per process. A call takes a few milliseconds of a thread,
|
|
21
|
+
# so 8 serve hundreds of recommendations a second; past that they queue, and
|
|
22
|
+
# recommend() falls back by its deadline.
|
|
23
|
+
THREADS = 8
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AsyncClient:
|
|
27
|
+
"""A :class:`Client` whose methods are coroutines; same arguments, same behaviour.
|
|
28
|
+
|
|
29
|
+
Create one per process. recommend()'s 1.5 s count from the ``await``: a
|
|
30
|
+
call still waiting for a free thread then goes straight to popular items.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, api_key: str, base_url: str, **options: Any) -> None:
|
|
34
|
+
self._client = Client(api_key, base_url, **options)
|
|
35
|
+
self._init_pool()
|
|
36
|
+
if hasattr(os, "register_at_fork"):
|
|
37
|
+
ref = weakref.ref(self)
|
|
38
|
+
os.register_at_fork(after_in_child=lambda: _call(ref, "_init_pool"))
|
|
39
|
+
|
|
40
|
+
def _init_pool(self) -> None:
|
|
41
|
+
# Also in a forked child, which has none of the parent's threads.
|
|
42
|
+
self._pool = ThreadPoolExecutor(THREADS, thread_name_prefix="recapi-async")
|
|
43
|
+
|
|
44
|
+
async def _run(self, fn: Any, *args: Any, **kwargs: Any) -> Any:
|
|
45
|
+
loop = asyncio.get_running_loop()
|
|
46
|
+
return await loop.run_in_executor(self._pool, functools.partial(fn, *args, **kwargs))
|
|
47
|
+
|
|
48
|
+
async def recommend(self, **params: Any) -> Recommendations:
|
|
49
|
+
"""Personalised recommendations: :meth:`Client.recommend`, same keywords."""
|
|
50
|
+
client = self._client
|
|
51
|
+
deadline = time.monotonic() + client._recommend_timeout
|
|
52
|
+
job = self._pool.submit(client._recommend, deadline, **params)
|
|
53
|
+
result = asyncio.wrap_future(job)
|
|
54
|
+
try:
|
|
55
|
+
return await asyncio.wait_for(asyncio.shield(result), client._recommend_timeout)
|
|
56
|
+
except TimeoutError:
|
|
57
|
+
if not job.cancel():
|
|
58
|
+
return await result # running: its own deadline has passed too
|
|
59
|
+
except asyncio.CancelledError:
|
|
60
|
+
job.cancel()
|
|
61
|
+
raise
|
|
62
|
+
# Every thread was still busy at the deadline: don't wait for one to
|
|
63
|
+
# serve the fallback. Past its deadline, the call asks only for popular items.
|
|
64
|
+
return await asyncio.to_thread(client._recommend, deadline, **params)
|
|
65
|
+
|
|
66
|
+
async def track(self, item_id: str, level: int | str, **ids: str | None) -> None:
|
|
67
|
+
"""Record an interaction: :meth:`Client.track`."""
|
|
68
|
+
await self._run(self._client.track, item_id, level, **ids)
|
|
69
|
+
|
|
70
|
+
async def identify(self, anon_id: str, user_id: str) -> None:
|
|
71
|
+
"""At login, the visitor's history becomes the user's: :meth:`Client.identify`."""
|
|
72
|
+
await self._run(self._client.identify, anon_id, user_id)
|
|
73
|
+
|
|
74
|
+
async def popular(self, **params: Any) -> list[Item]:
|
|
75
|
+
"""Popular items: :meth:`Client.popular`."""
|
|
76
|
+
return await self._run(self._client.popular, **params)
|
|
77
|
+
|
|
78
|
+
async def upsert_items(self, items: Any) -> int:
|
|
79
|
+
"""Create or replace items: :meth:`Client.upsert_items`."""
|
|
80
|
+
return await self._run(self._client.upsert_items, items)
|
|
81
|
+
|
|
82
|
+
async def delete_user(self, user_id: str) -> None:
|
|
83
|
+
"""Erase a user (GDPR): :meth:`Client.delete_user`."""
|
|
84
|
+
await self._run(self._client.delete_user, user_id)
|
|
85
|
+
|
|
86
|
+
async def flush(self) -> None:
|
|
87
|
+
"""Send every queued event now: :meth:`Client.flush`."""
|
|
88
|
+
await self._run(self._client.flush)
|
|
89
|
+
|
|
90
|
+
async def close(self) -> None:
|
|
91
|
+
"""Send what is queued and stop the sender: :meth:`Client.close` (also runs at exit).
|
|
92
|
+
|
|
93
|
+
As with Client, later calls still work (their events are sent at once).
|
|
94
|
+
"""
|
|
95
|
+
await self._run(self._client.close)
|