recapi 0.4.1__tar.gz → 0.4.2__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/recapi.egg-info → recapi-0.4.2}/PKG-INFO +6 -5
- {recapi-0.4.1 → recapi-0.4.2}/README.md +5 -4
- {recapi-0.4.1 → recapi-0.4.2}/pyproject.toml +1 -1
- {recapi-0.4.1 → recapi-0.4.2}/recapi/client.py +24 -5
- {recapi-0.4.1 → recapi-0.4.2/recapi.egg-info}/PKG-INFO +6 -5
- {recapi-0.4.1 → recapi-0.4.2}/tests/test_client.py +57 -0
- {recapi-0.4.1 → recapi-0.4.2}/tests/test_client_vectors.py +3 -3
- {recapi-0.4.1 → recapi-0.4.2}/LICENSE +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/recapi/__init__.py +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/recapi/async_client.py +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/recapi/history.py +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/recapi.egg-info/SOURCES.txt +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/recapi.egg-info/dependency_links.txt +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/recapi.egg-info/top_level.txt +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/setup.cfg +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/tests/test_async_client.py +0 -0
- {recapi-0.4.1 → recapi-0.4.2}/tests/test_history.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: recapi
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Python client for the recapi recommendation API: per-user history, event batching, retries, popular fallback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -28,7 +28,7 @@ Your recapi API serves the library: install it with pip, or put the same URL
|
|
|
28
28
|
in your `requirements.txt`. No package index, no build step, no dependencies:
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
pip install https://api.<your-domain>/sdk/python/recapi-0.4.
|
|
31
|
+
pip install https://api.<your-domain>/sdk/python/recapi-0.4.2-py3-none-any.whl
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
The dashboard's quick start shows this line with your API's address. To work
|
|
@@ -110,8 +110,9 @@ recapi uses the history it keeps from that user's events, and returns it with
|
|
|
110
110
|
the recommendations; the client stores it for the next calls. If the store's
|
|
111
111
|
first call for that user was a `track()` or an `identify()`, the first
|
|
112
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
|
|
114
|
-
|
|
113
|
+
with half of its 1.5 s and merges it with what was tracked here (after an
|
|
114
|
+
`identify()`, without the visitor's browsing, which is already merged here); a
|
|
115
|
+
failed fetch is tried again on the next call. This works for `user_id` only, and
|
|
115
116
|
only with a secret key. A store that fails
|
|
116
117
|
(Redis down) is logged and skipped: tracking and recommendations go on.
|
|
117
118
|
Updates to one user are serialised within a process only: two processes
|
|
@@ -184,7 +185,7 @@ one recommendation id collects the events of many users ("Page caches").
|
|
|
184
185
|
| `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
186
|
| `popular(*, count=10, tags_any=None, tags_all=None)` | Popular items (`Item`, metadata and `why` included), for pages without a user |
|
|
186
187
|
| `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
|
+
| `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. The user's first restore from recapi then leaves that visitor's browsing out, so it doesn't count twice |
|
|
188
189
|
| `delete_user(user_id)` | Erases the user's events in recapi (GDPR) and their state in the store |
|
|
189
190
|
| `flush()` / `close()` | Send queued events now; `close()` also stops the background sender |
|
|
190
191
|
|
|
@@ -18,7 +18,7 @@ Your recapi API serves the library: install it with pip, or put the same URL
|
|
|
18
18
|
in your `requirements.txt`. No package index, no build step, no dependencies:
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
|
-
pip install https://api.<your-domain>/sdk/python/recapi-0.4.
|
|
21
|
+
pip install https://api.<your-domain>/sdk/python/recapi-0.4.2-py3-none-any.whl
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
The dashboard's quick start shows this line with your API's address. To work
|
|
@@ -100,8 +100,9 @@ recapi uses the history it keeps from that user's events, and returns it with
|
|
|
100
100
|
the recommendations; the client stores it for the next calls. If the store's
|
|
101
101
|
first call for that user was a `track()` or an `identify()`, the first
|
|
102
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
|
|
104
|
-
|
|
103
|
+
with half of its 1.5 s and merges it with what was tracked here (after an
|
|
104
|
+
`identify()`, without the visitor's browsing, which is already merged here); a
|
|
105
|
+
failed fetch is tried again on the next call. This works for `user_id` only, and
|
|
105
106
|
only with a secret key. A store that fails
|
|
106
107
|
(Redis down) is logged and skipped: tracking and recommendations go on.
|
|
107
108
|
Updates to one user are serialised within a process only: two processes
|
|
@@ -174,7 +175,7 @@ one recommendation id collects the events of many users ("Page caches").
|
|
|
174
175
|
| `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
176
|
| `popular(*, count=10, tags_any=None, tags_all=None)` | Popular items (`Item`, metadata and `why` included), for pages without a user |
|
|
176
177
|
| `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
|
+
| `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. The user's first restore from recapi then leaves that visitor's browsing out, so it doesn't count twice |
|
|
178
179
|
| `delete_user(user_id)` | Erases the user's events in recapi (GDPR) and their state in the store |
|
|
179
180
|
| `flush()` / `close()` | Send queued events now; `close()` also stops the background sender |
|
|
180
181
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "recapi"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Python client for the recapi recommendation API: per-user history, event batching, retries, popular fallback."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -29,7 +29,7 @@ from typing import Any, Protocol
|
|
|
29
29
|
|
|
30
30
|
from .history import History, is_id
|
|
31
31
|
|
|
32
|
-
__version__ = "0.4.
|
|
32
|
+
__version__ = "0.4.2"
|
|
33
33
|
|
|
34
34
|
log = logging.getLogger("recapi")
|
|
35
35
|
Tags = Sequence[str] | None
|
|
@@ -196,22 +196,32 @@ class _UserState:
|
|
|
196
196
|
|
|
197
197
|
``shown`` maps item -> [recommendation id, shown at, clicked at?], oldest
|
|
198
198
|
first. ``restored`` is set once the history holds recapi's stored one.
|
|
199
|
+
``anon_id`` is the visitor identify() merged in before that: recapi's
|
|
200
|
+
snapshot also holds that visitor's browsing, so the restore asks it to
|
|
201
|
+
leave it out.
|
|
199
202
|
"""
|
|
200
203
|
|
|
201
|
-
__slots__ = ("history", "restored", "shown")
|
|
204
|
+
__slots__ = ("anon_id", "history", "restored", "shown")
|
|
202
205
|
|
|
203
206
|
def __init__(
|
|
204
|
-
self,
|
|
207
|
+
self,
|
|
208
|
+
history: History | None = None,
|
|
209
|
+
shown: dict | None = None,
|
|
210
|
+
restored: bool = False,
|
|
211
|
+
anon_id: str | None = None,
|
|
205
212
|
) -> None:
|
|
206
213
|
self.history = history or History()
|
|
207
214
|
self.shown: dict[str, list[Any]] = shown or {}
|
|
208
215
|
self.restored = restored
|
|
216
|
+
self.anon_id = anon_id
|
|
209
217
|
|
|
210
218
|
def dumps(self, now: int) -> str:
|
|
211
219
|
shown = [[item, *entry] for item, entry in self.shown.items() if _attributable(entry, now)]
|
|
212
220
|
state = {"history": self.history.encode(), "shown": shown[-MAX_SHOWN_ITEMS:]}
|
|
213
221
|
if self.restored:
|
|
214
222
|
state["restored"] = True
|
|
223
|
+
if self.anon_id:
|
|
224
|
+
state["anon_id"] = self.anon_id
|
|
215
225
|
return json.dumps(state, separators=(",", ":"))
|
|
216
226
|
|
|
217
227
|
@classmethod
|
|
@@ -231,7 +241,10 @@ class _UserState:
|
|
|
231
241
|
if history is None:
|
|
232
242
|
log.warning("recapi: discarding a malformed stored user state")
|
|
233
243
|
return cls()
|
|
234
|
-
|
|
244
|
+
anon_id = data.get("anon_id")
|
|
245
|
+
return cls(
|
|
246
|
+
history, shown, data.get("restored") is True, anon_id if is_id(anon_id) else None
|
|
247
|
+
)
|
|
235
248
|
|
|
236
249
|
|
|
237
250
|
def _attributable(entry: Sequence[Any], t: int) -> bool:
|
|
@@ -628,9 +641,11 @@ class Client:
|
|
|
628
641
|
a failure leaves the state as it was, to be tried on the next call.
|
|
629
642
|
"""
|
|
630
643
|
path = "/v1/users/" + urllib.parse.quote(user_id, safe="") + "/history"
|
|
644
|
+
query = {"anon_id": state.anon_id} if state.anon_id else None
|
|
631
645
|
try:
|
|
632
646
|
# Half the time left, so the recommendation itself still fits.
|
|
633
|
-
|
|
647
|
+
timeout = (deadline - time.monotonic()) / 2
|
|
648
|
+
snapshot = self._request("GET", path, timeout=timeout, query=query)
|
|
634
649
|
if not isinstance(snapshot, dict):
|
|
635
650
|
raise RecapiError("invalid_response", "malformed history response")
|
|
636
651
|
except RecapiError as error:
|
|
@@ -642,6 +657,7 @@ class Client:
|
|
|
642
657
|
if not state.restored:
|
|
643
658
|
state.history = state.history.merge_snapshot(History.from_snapshot(snapshot), t)
|
|
644
659
|
state.restored = True
|
|
660
|
+
state.anon_id = None
|
|
645
661
|
self._save(key, state, t)
|
|
646
662
|
return state
|
|
647
663
|
|
|
@@ -664,6 +680,9 @@ class Client:
|
|
|
664
680
|
return
|
|
665
681
|
state = self._load(user)
|
|
666
682
|
state.history = state.history.merge(guest.history, t)
|
|
683
|
+
if not state.restored:
|
|
684
|
+
# recapi's snapshot, fetched later, holds this visitor's browsing too.
|
|
685
|
+
state.anon_id = anon_id
|
|
667
686
|
# Oldest display first, as recommend() keeps them; an item's later display wins.
|
|
668
687
|
shown = sorted([*state.shown.items(), *guest.shown.items()], key=lambda e: e[1][1])
|
|
669
688
|
state.shown = {}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: recapi
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Python client for the recapi recommendation API: per-user history, event batching, retries, popular fallback.
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -28,7 +28,7 @@ Your recapi API serves the library: install it with pip, or put the same URL
|
|
|
28
28
|
in your `requirements.txt`. No package index, no build step, no dependencies:
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
|
-
pip install https://api.<your-domain>/sdk/python/recapi-0.4.
|
|
31
|
+
pip install https://api.<your-domain>/sdk/python/recapi-0.4.2-py3-none-any.whl
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
The dashboard's quick start shows this line with your API's address. To work
|
|
@@ -110,8 +110,9 @@ recapi uses the history it keeps from that user's events, and returns it with
|
|
|
110
110
|
the recommendations; the client stores it for the next calls. If the store's
|
|
111
111
|
first call for that user was a `track()` or an `identify()`, the first
|
|
112
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
|
|
114
|
-
|
|
113
|
+
with half of its 1.5 s and merges it with what was tracked here (after an
|
|
114
|
+
`identify()`, without the visitor's browsing, which is already merged here); a
|
|
115
|
+
failed fetch is tried again on the next call. This works for `user_id` only, and
|
|
115
116
|
only with a secret key. A store that fails
|
|
116
117
|
(Redis down) is logged and skipped: tracking and recommendations go on.
|
|
117
118
|
Updates to one user are serialised within a process only: two processes
|
|
@@ -184,7 +185,7 @@ one recommendation id collects the events of many users ("Page caches").
|
|
|
184
185
|
| `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
186
|
| `popular(*, count=10, tags_any=None, tags_all=None)` | Popular items (`Item`, metadata and `why` included), for pages without a user |
|
|
186
187
|
| `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
|
+
| `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. The user's first restore from recapi then leaves that visitor's browsing out, so it doesn't count twice |
|
|
188
189
|
| `delete_user(user_id)` | Erases the user's events in recapi (GDPR) and their state in the store |
|
|
189
190
|
| `flush()` / `close()` | Send queued events now; `close()` also stops the background sender |
|
|
190
191
|
|
|
@@ -971,6 +971,63 @@ class Restore(APITest):
|
|
|
971
971
|
client.recommend(user_id="u/1 é")
|
|
972
972
|
self.assertEqual(self.api.calls[0].raw_path, "/v1/users/u%2F1%20%C3%A9/history")
|
|
973
973
|
|
|
974
|
+
def test_the_visitor_of_a_login_is_left_out_of_the_restore_until_it_succeeds(self) -> None:
|
|
975
|
+
self.api.on(self.HISTORY, Reply(503, {"error": {"code": "unavailable", "message": "x"}}))
|
|
976
|
+
store = recapi.MemoryStore()
|
|
977
|
+
client = self.client(store=store)
|
|
978
|
+
client.track("a", "view", anon_id="v")
|
|
979
|
+
client.identify("v", "u")
|
|
980
|
+
with self.assertLogs("recapi", "WARNING"):
|
|
981
|
+
client.recommend(user_id="u")
|
|
982
|
+
# The failed restore keeps the visitor, also for another process.
|
|
983
|
+
self.assertIn('"anon_id":"v"', store.get(client._key("u", None)))
|
|
984
|
+
self.api.on(self.HISTORY, Reply(200, {"top": [], "recent": [], "as_of": T0}))
|
|
985
|
+
other = self.client(store=store)
|
|
986
|
+
other.recommend(user_id="u")
|
|
987
|
+
other.recommend(user_id="u")
|
|
988
|
+
history = f"GET {self.HISTORY}?anon_id=v"
|
|
989
|
+
recommend = "POST /v1/recommendations"
|
|
990
|
+
self.assertEqual(self.api.lines(), [history, recommend, history, recommend, recommend])
|
|
991
|
+
self.assertNotIn("anon_id", store.get(client._key("u", None)))
|
|
992
|
+
|
|
993
|
+
def test_a_login_after_the_restore_or_an_invalid_stored_visitor_asks_for_no_visitor(
|
|
994
|
+
self,
|
|
995
|
+
) -> None:
|
|
996
|
+
store = recapi.MemoryStore()
|
|
997
|
+
client = self.client(store=store)
|
|
998
|
+
restored = {
|
|
999
|
+
"history": {
|
|
1000
|
+
"top": [["s", 3.0, T0]],
|
|
1001
|
+
"recent": [["s", T0]],
|
|
1002
|
+
"rejected": [],
|
|
1003
|
+
"lastT": T0,
|
|
1004
|
+
},
|
|
1005
|
+
"shown": [],
|
|
1006
|
+
"restored": True,
|
|
1007
|
+
}
|
|
1008
|
+
store.set(client._key("u", None), json.dumps(restored))
|
|
1009
|
+
client.track("a", "view", anon_id="v")
|
|
1010
|
+
client.identify("v", "u")
|
|
1011
|
+
self.assertNotIn("anon_id", store.get(client._key("u", None)))
|
|
1012
|
+
# Written by a newer version, or by hand: an id the API would refuse is dropped.
|
|
1013
|
+
invalid = {
|
|
1014
|
+
"history": {
|
|
1015
|
+
"top": [["a", 3.0, T0]],
|
|
1016
|
+
"recent": [["a", T0]],
|
|
1017
|
+
"rejected": [],
|
|
1018
|
+
"lastT": T0,
|
|
1019
|
+
},
|
|
1020
|
+
"shown": [],
|
|
1021
|
+
"anon_id": "v\n",
|
|
1022
|
+
}
|
|
1023
|
+
store.set(client._key("w", None), json.dumps(invalid))
|
|
1024
|
+
client.recommend(user_id="u")
|
|
1025
|
+
client.recommend(user_id="w")
|
|
1026
|
+
self.assertEqual(
|
|
1027
|
+
self.api.lines(),
|
|
1028
|
+
["POST /v1/recommendations", "GET /v1/users/w/history", "POST /v1/recommendations"],
|
|
1029
|
+
)
|
|
1030
|
+
|
|
974
1031
|
|
|
975
1032
|
class Identify(APITest):
|
|
976
1033
|
def test_a_visitors_history_and_clicks_count_after_login(self) -> None:
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from unittest import mock
|
|
8
8
|
|
|
9
9
|
import recapi
|
|
10
|
-
from fakeapi import KEY, T0, APITest, Reply, dead_url, popular, recs
|
|
10
|
+
from fakeapi import KEY, T0, APITest, Reply, dead_url, popular, recs, request_line
|
|
11
11
|
from recapi import client as rc
|
|
12
12
|
|
|
13
13
|
VECTORS = json.loads(
|
|
@@ -226,7 +226,7 @@ class ClientVectors(APITest):
|
|
|
226
226
|
with self.assertNoLogs("recapi") if "stored" in case else self.assertLogs("recapi"):
|
|
227
227
|
client.recommend(user_id="u")
|
|
228
228
|
client.recommend(user_id="u")
|
|
229
|
-
self.assertEqual(
|
|
229
|
+
self.assertEqual(self.api.lines(), case["requests"])
|
|
230
230
|
body = next(c.body for c in self.api.calls if c.path == "/v1/recommendations")
|
|
231
231
|
sent = {k: body.get(k) for k in ("history", "recent", "exclude")}
|
|
232
232
|
self.assertEqual(sent, case["sent"])
|
|
@@ -257,7 +257,7 @@ class ClientVectors(APITest):
|
|
|
257
257
|
start = len(self.api.calls)
|
|
258
258
|
client.recommend(user_id="u")
|
|
259
259
|
calls = self.api.calls[start:]
|
|
260
|
-
self.assertEqual([
|
|
260
|
+
self.assertEqual([request_line(c) for c in calls], case["requests"])
|
|
261
261
|
body = calls[-1].body
|
|
262
262
|
self.assertEqual(
|
|
263
263
|
{k: body.get(k) for k in ("history", "recent", "exclude")}, case["sent"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|