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 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)