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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: recapi
3
- Version: 0.4.1
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.1-py3-none-any.whl
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; a failed
114
- fetch is tried again on the next call. This works for `user_id` only, and
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.1-py3-none-any.whl
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; a failed
104
- fetch is tried again on the next call. This works for `user_id` only, and
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.1"
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.1"
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, history: History | None = None, shown: dict | None = None, restored: bool = False
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
- return cls(history, shown, data.get("restored") is True)
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
- snapshot = self._request("GET", path, timeout=(deadline - time.monotonic()) / 2)
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.1
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.1-py3-none-any.whl
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; a failed
114
- fetch is tried again on the next call. This works for `user_id` only, and
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([f"{c.method} {c.path}" for c in self.api.calls], case["requests"])
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([f"{c.method} {c.path}" for c in calls], case["requests"])
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