recapi 0.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- recapi/__init__.py +38 -0
- recapi/async_client.py +95 -0
- recapi/client.py +1018 -0
- recapi/history.py +269 -0
- recapi-0.4.1.dist-info/METADATA +212 -0
- recapi-0.4.1.dist-info/RECORD +9 -0
- recapi-0.4.1.dist-info/WHEEL +5 -0
- recapi-0.4.1.dist-info/licenses/LICENSE +21 -0
- recapi-0.4.1.dist-info/top_level.txt +1 -0
recapi/__init__.py
ADDED
|
@@ -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}")
|
recapi/async_client.py
ADDED
|
@@ -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)
|