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