tai42-kit 0.1.0__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.
Files changed (66) hide show
  1. tai42_kit/__init__.py +20 -0
  2. tai42_kit/clients/__init__.py +27 -0
  3. tai42_kit/clients/base.py +203 -0
  4. tai42_kit/clients/facade.py +44 -0
  5. tai42_kit/clients/impl/__init__.py +3 -0
  6. tai42_kit/clients/impl/curl.py +41 -0
  7. tai42_kit/clients/impl/http.py +26 -0
  8. tai42_kit/clients/impl/mcp.py +60 -0
  9. tai42_kit/clients/impl/postgres.py +58 -0
  10. tai42_kit/clients/impl/redis.py +120 -0
  11. tai42_kit/clients/settings.py +159 -0
  12. tai42_kit/llm/__init__.py +23 -0
  13. tai42_kit/llm/_resource_registry.py +150 -0
  14. tai42_kit/llm/_secret_kwargs.py +84 -0
  15. tai42_kit/llm/checkpoint/__init__.py +0 -0
  16. tai42_kit/llm/checkpoint/checkpoint.py +147 -0
  17. tai42_kit/llm/checkpoint/checkpoint_registry.py +39 -0
  18. tai42_kit/llm/embedding.py +51 -0
  19. tai42_kit/llm/middleware/__init__.py +0 -0
  20. tai42_kit/llm/middleware/context_overflow.py +130 -0
  21. tai42_kit/llm/middleware/trimming.py +64 -0
  22. tai42_kit/llm/models.py +61 -0
  23. tai42_kit/llm/runtime.py +123 -0
  24. tai42_kit/llm/settings.py +166 -0
  25. tai42_kit/llm/store/__init__.py +0 -0
  26. tai42_kit/llm/store/store.py +139 -0
  27. tai42_kit/llm/store/store_registry.py +59 -0
  28. tai42_kit/logging/__init__.py +10 -0
  29. tai42_kit/logging/logger.py +25 -0
  30. tai42_kit/logging/settings.py +35 -0
  31. tai42_kit/net/__init__.py +28 -0
  32. tai42_kit/net/fetch_url.py +153 -0
  33. tai42_kit/net/jwt.py +319 -0
  34. tai42_kit/net/url_guard.py +135 -0
  35. tai42_kit/plugins/__init__.py +233 -0
  36. tai42_kit/py.typed +0 -0
  37. tai42_kit/settings/__init__.py +25 -0
  38. tai42_kit/settings/base.py +48 -0
  39. tai42_kit/settings/cache_registry.py +37 -0
  40. tai42_kit/settings/registry.py +279 -0
  41. tai42_kit/transport/__init__.py +17 -0
  42. tai42_kit/transport/base_uds_transport.py +72 -0
  43. tai42_kit/transport/http_uds_transport.py +20 -0
  44. tai42_kit/transport/sse_uds_transport.py +20 -0
  45. tai42_kit/transport/utils.py +27 -0
  46. tai42_kit/utils/__init__.py +1 -0
  47. tai42_kit/utils/data/__init__.py +31 -0
  48. tai42_kit/utils/data/jq_util.py +47 -0
  49. tai42_kit/utils/data/json_schema_util.py +470 -0
  50. tai42_kit/utils/data/mcp_output_util.py +93 -0
  51. tai42_kit/utils/data/string_util.py +28 -0
  52. tai42_kit/utils/data/url_util.py +9 -0
  53. tai42_kit/utils/data/yaml_util.py +136 -0
  54. tai42_kit/utils/lc/__init__.py +54 -0
  55. tai42_kit/utils/lc/lc_util.py +57 -0
  56. tai42_kit/utils/lc/signature_util.py +54 -0
  57. tai42_kit/utils/runtime/__init__.py +12 -0
  58. tai42_kit/utils/runtime/files_util.py +122 -0
  59. tai42_kit/utils/runtime/schedule_util.py +112 -0
  60. tai42_kit/utils/runtime/uvicorn_util.py +140 -0
  61. tai42_kit-0.1.0.dist-info/METADATA +163 -0
  62. tai42_kit-0.1.0.dist-info/RECORD +66 -0
  63. tai42_kit-0.1.0.dist-info/WHEEL +5 -0
  64. tai42_kit-0.1.0.dist-info/licenses/LICENSE +202 -0
  65. tai42_kit-0.1.0.dist-info/licenses/NOTICE +5 -0
  66. tai42_kit-0.1.0.dist-info/top_level.txt +1 -0
tai42_kit/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """tai42-kit — generic leaf helpers, LLM factories, pooled clients, and settings
2
+ for the TAI ecosystem.
3
+
4
+ Utilities (data/runtime/langchain helpers), LLM provider + checkpoint/store
5
+ factories, pooled clients + MCP transports, and co-located pydantic-settings.
6
+ The only tai-* dependency is ``tai42-contract`` (the leaf rule): kit implements its
7
+ ``BaseClient`` Protocol and consumes its manifest types; it imports no other
8
+ tai-* package.
9
+
10
+ Deliberately light: nothing is re-exported here, so ``import tai42_kit`` pulls no
11
+ vendor dependency (no langchain, no drivers). Import the subpackage you need —
12
+ each one (``tai42_kit.llm``, ``tai42_kit.clients``, ...) is the opt-in boundary for
13
+ its dependencies.
14
+ """
15
+
16
+ from importlib.metadata import version
17
+
18
+ __version__ = version("tai42-kit")
19
+
20
+ __all__ = ["__version__"]
@@ -0,0 +1,27 @@
1
+ """Pooled clients + the generic client facade.
2
+
3
+ Each client subclasses ``PooledClient`` (per-loop pooling, satisfies the contract
4
+ ``BaseClient`` Protocol) and is reached by class through ``client_ctx``. The
5
+ driver each impl needs is declared by an extra (``tai42-kit[curl]`` /
6
+ ``[postgres]``); import a driver submodule only when its driver is installed.
7
+ """
8
+
9
+ from tai42_contract.errors import ClientDisconnectedError
10
+
11
+ from tai42_kit.clients.base import PooledClient, shutdown_all_clients
12
+ from tai42_kit.clients.facade import client_ctx
13
+ from tai42_kit.clients.settings import (
14
+ ClientSettings,
15
+ PostgresConnectionSettings,
16
+ RedisConnectionSettings,
17
+ )
18
+
19
+ __all__ = [
20
+ "ClientDisconnectedError",
21
+ "ClientSettings",
22
+ "PooledClient",
23
+ "PostgresConnectionSettings",
24
+ "RedisConnectionSettings",
25
+ "client_ctx",
26
+ "shutdown_all_clients",
27
+ ]
@@ -0,0 +1,203 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import threading
5
+ from asyncio import AbstractEventLoop
6
+ from collections.abc import AsyncIterator
7
+ from contextlib import asynccontextmanager
8
+ from typing import cast
9
+ from weakref import WeakKeyDictionary
10
+
11
+ from tai42_contract.errors import ClientDisconnectedError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Clients are cached per event loop (async pools are bound to the loop that
16
+ # created them) and per PooledClient subclass + connection key. Keyed off the
17
+ # loop in a WeakKeyDictionary so a loop's caches drop when the loop is collected —
18
+ # no attribute is stashed on the loop object.
19
+ _loop_clients: "WeakKeyDictionary[AbstractEventLoop, dict[type, dict[str, object]]]" = WeakKeyDictionary()
20
+ _loop_locks: "WeakKeyDictionary[AbstractEventLoop, dict[tuple[type, str], asyncio.Lock]]" = WeakKeyDictionary()
21
+
22
+ # The two maps above are module-global and reached from many threads: a template
23
+ # render runs in a worker thread (asyncio.to_thread) whose {% include %} bridge
24
+ # spins its own loop (asyncio.run), so concurrent renders touch these maps at the
25
+ # same time. WeakKeyDictionary is not thread-safe, so every EXPLICIT access to the
26
+ # OUTER maps is serialized under this lock. The lock is held only around dict
27
+ # bookkeeping, never across an await (client creation/closing happens outside it).
28
+ # The weak-ref cleanup of a collected loop's entry still fires off-lock on an
29
+ # arbitrary thread, but that removal is a single C-level op (atomic under the
30
+ # GIL). Inner per-loop dicts stay owned by their single loop.
31
+ _registry_lock = threading.Lock()
32
+
33
+ # RuntimeError messages asyncio emits when a loop-bound resource is used after
34
+ # its event loop closed or from a different loop. Driver stacks that surface a
35
+ # dead session as a plain RuntimeError (curl_cffi, anyio) produce one of these;
36
+ # any other RuntimeError comes from caller code and must propagate untouched.
37
+ _LOOP_BOUND_ERROR_MARKERS = (
38
+ "Event loop is closed",
39
+ "attached to a different loop",
40
+ "bound to a different event loop",
41
+ )
42
+
43
+
44
+ def is_loop_bound_runtime_error(exc: BaseException) -> bool:
45
+ """Whether ``exc`` is a RuntimeError signalling a closed/mismatched event loop."""
46
+ return isinstance(exc, RuntimeError) and any(marker in str(exc) for marker in _LOOP_BOUND_ERROR_MARKERS)
47
+
48
+
49
+ def reject_unknown_connection_kwargs(client_label: str, kwargs: dict[str, object], allowed: frozenset[str]) -> None:
50
+ """Reject connection kwargs a client does not understand, raising ``ValueError``.
51
+
52
+ A pooled client keys its per-loop pool on the full kwargs dict, so an
53
+ unrecognized kwarg (typically a typo) would otherwise split the pool key and
54
+ silently create a second, mis-configured client rather than the caller's
55
+ intended one. The shared validation convention across the concrete impls:
56
+ fail loudly, naming the offending keys and the allowed set.
57
+ """
58
+ unknown = sorted(set(kwargs) - allowed)
59
+ if unknown:
60
+ raise ValueError(f"{client_label} received unknown connection kwarg(s) {unknown}; allowed: {sorted(allowed)}")
61
+
62
+
63
+ class PooledClient[T]:
64
+ @staticmethod
65
+ def _key(**kwargs) -> str:
66
+ return json.dumps(kwargs, sort_keys=True)
67
+
68
+ def _clients_for_loop(self, loop: AbstractEventLoop) -> dict[str, T]:
69
+ with _registry_lock:
70
+ per_loop = _loop_clients.get(loop)
71
+ if per_loop is None:
72
+ per_loop = {}
73
+ _loop_clients[loop] = per_loop
74
+ # The registry is heterogeneous across subclasses (values typed
75
+ # ``object``); a single class's entries are all ``T``.
76
+ return cast("dict[str, T]", per_loop.setdefault(self.__class__, {}))
77
+
78
+ def _create_lock(self, loop: AbstractEventLoop, key: str) -> asyncio.Lock:
79
+ with _registry_lock:
80
+ per_loop = _loop_locks.get(loop)
81
+ if per_loop is None:
82
+ per_loop = {}
83
+ _loop_locks[loop] = per_loop
84
+ lock = per_loop.get((self.__class__, key))
85
+ if lock is None:
86
+ lock = asyncio.Lock()
87
+ per_loop[(self.__class__, key)] = lock
88
+ return lock
89
+
90
+ @asynccontextmanager
91
+ async def current(self, **kwargs) -> AsyncIterator[T]:
92
+ loop = asyncio.get_running_loop()
93
+ clients_for_loop = self._clients_for_loop(loop)
94
+ key = self._key(**kwargs)
95
+ # Double-checked creation under a per-(class, key) lock: without it two
96
+ # concurrent first-use callers both create a client and one is orphaned
97
+ # (never closed). The synchronous get/setdefault above never awaits, so
98
+ # the cache and lock lookups are themselves race-free on the single loop.
99
+ if key not in clients_for_loop:
100
+ async with self._create_lock(loop, key):
101
+ # Re-fetch the live inner dict now that the lock is held.
102
+ # ``shutdown_all_clients()`` may have popped this loop's pool out
103
+ # of ``_loop_clients`` — detaching and clearing the dict captured
104
+ # before the lock — between that capture and acquiring the lock.
105
+ # Both the re-check and the write below must see the LIVE dict: a
106
+ # second waiter re-checking the stale-cleared dict would find
107
+ # ``key`` absent, create a duplicate, and overwrite the first
108
+ # waiter's client in the re-registered dict — orphaning it (never
109
+ # closed). The re-fetch and re-check are synchronous, so on the
110
+ # single loop nothing interleaves between them.
111
+ clients_for_loop = self._clients_for_loop(loop)
112
+ if key not in clients_for_loop:
113
+ created = await self._create(**kwargs)
114
+ # ``_create`` awaited above, so a shutdown may have detached
115
+ # the pool again. Re-fetch once more so the write lands in the
116
+ # live (re-registered if it was swept) dict rather than a
117
+ # stale one, where a future shutdown/close can reach it. The
118
+ # re-fetch and write are synchronous — nothing interleaves.
119
+ clients_for_loop = self._clients_for_loop(loop)
120
+ clients_for_loop[key] = created
121
+
122
+ client = clients_for_loop[key]
123
+ try:
124
+ yield client
125
+ except BaseException as e:
126
+ # Only errors the predicate classifies as a disconnection evict the
127
+ # client; everything else (including cancellation) re-raises
128
+ # untouched.
129
+ if not self._is_disconnection_error(e):
130
+ raise
131
+ clients_for_loop.pop(key, None)
132
+ try:
133
+ await self._close(client)
134
+ except Exception:
135
+ logger.debug("Failed to close disconnected client %s", type(client).__name__, exc_info=True)
136
+ raise ClientDisconnectedError(
137
+ f"{type(client).__name__} disconnected and was removed from the cache. "
138
+ f"Retry the operation to create a new client. (Original error: {e})"
139
+ ) from e
140
+
141
+ async def close(self, **kwargs) -> None:
142
+ loop = asyncio.get_running_loop()
143
+ clients_for_loop = self._clients_for_loop(loop)
144
+
145
+ key = self._key(**kwargs)
146
+ client = clients_for_loop.pop(key, None)
147
+ if client is not None:
148
+ await self._close(client)
149
+
150
+ async def _create(self, **kwargs) -> T:
151
+ raise NotImplementedError
152
+
153
+ async def _close(self, client: T) -> None:
154
+ raise NotImplementedError
155
+
156
+ def _disconnection_exceptions(self) -> tuple[type[BaseException], ...]:
157
+ # Cancellation is deliberately excluded: a cancelled ``current()`` body
158
+ # must re-raise ``CancelledError`` so the await point unwinds, never be
159
+ # converted into ``ClientDisconnectedError``. Concrete clients override
160
+ # this with their driver's real disconnection set.
161
+ return (ConnectionError,)
162
+
163
+ def _is_disconnection_error(self, exc: BaseException) -> bool:
164
+ # Predicate ``current()`` consults to decide whether an error from the
165
+ # body means the pooled client is dead (evict + close + wrap in
166
+ # ``ClientDisconnectedError``) or is an ordinary caller error
167
+ # (propagate untouched). Defaults to an isinstance check against
168
+ # ``_disconnection_exceptions()``; clients whose drivers signal
169
+ # disconnection through a broad exception type (e.g. a plain
170
+ # ``RuntimeError``) override this to also match by message.
171
+ return isinstance(exc, self._disconnection_exceptions())
172
+
173
+
174
+ async def shutdown_all_clients() -> None:
175
+ """Close every live client pool for the running event loop.
176
+
177
+ Sweeps the per-loop class-keyed cache and closes each pooled client. Closes
178
+ are best-effort — failures are collected and raised together, never silently
179
+ dropped.
180
+ """
181
+ # Always called from within a running loop (it is async); get_running_loop
182
+ # raises loudly if that invariant is ever broken.
183
+ loop = asyncio.get_running_loop()
184
+ # Detach this loop's pool atomically, then close its clients without holding
185
+ # the registry lock across the awaits.
186
+ with _registry_lock:
187
+ per_loop = _loop_clients.pop(loop, None)
188
+ if not per_loop:
189
+ return
190
+
191
+ errors = []
192
+ for cls, clients in list(per_loop.items()):
193
+ closer = cls()
194
+ for client in list(clients.values()):
195
+ try:
196
+ await closer._close(client)
197
+ except Exception as e:
198
+ logger.exception("Error closing client %s", type(client).__name__)
199
+ errors.append(e)
200
+ clients.clear()
201
+
202
+ if errors:
203
+ raise ExceptionGroup("Errors while shutting down clients", errors)
@@ -0,0 +1,44 @@
1
+ """The generic client facade — one way to get any pooled client.
2
+
3
+ ``client_ctx(SomeClient, settings)`` returns an async context manager yielding a
4
+ connected client, pooled and reused per event-loop + connection params (the
5
+ pooling lives in ``PooledClient``). A client is a plain ``PooledClient`` subclass,
6
+ and callers reach it by class — no container, no wiring.
7
+
8
+ - ``settings`` (a ``ClientSettings``) supplies connection kwargs via
9
+ ``client_kwargs()``; or pass raw ``**kwargs`` for clients with none.
10
+ - ``fresh=True`` yields a one-shot client built outside the pool and closed on
11
+ exit (for a blocking op that must not hold a shared-pool connection).
12
+ """
13
+
14
+ from collections.abc import AsyncIterator
15
+ from contextlib import AbstractAsyncContextManager, asynccontextmanager
16
+ from typing import Any
17
+
18
+ from tai42_kit.clients.base import PooledClient
19
+ from tai42_kit.clients.settings import ClientSettings
20
+
21
+
22
+ def client_ctx[T](
23
+ client_cls: type[PooledClient[T]],
24
+ settings: ClientSettings | None = None,
25
+ *,
26
+ fresh: bool = False,
27
+ **kwargs: Any,
28
+ ) -> AbstractAsyncContextManager[T]:
29
+ if settings is not None and kwargs:
30
+ raise ValueError("client_ctx: pass either `settings` or **kwargs, not both")
31
+ kw = settings.client_kwargs() if settings is not None else kwargs
32
+ if fresh:
33
+ return _fresh_client(client_cls, kw)
34
+ return client_cls().current(**kw)
35
+
36
+
37
+ @asynccontextmanager
38
+ async def _fresh_client[T](client_cls: type[PooledClient[T]], kw: dict) -> AsyncIterator[T]:
39
+ inst = client_cls()
40
+ client = await inst._create(**kw)
41
+ try:
42
+ yield client
43
+ finally:
44
+ await inst._close(client)
@@ -0,0 +1,3 @@
1
+ """Concrete pooled clients. Each imports its driver at module top, so import the
2
+ submodule only with the matching extra installed (``tai42-kit[redis]`` / ``[curl]``
3
+ / ``[postgres]``)."""
@@ -0,0 +1,41 @@
1
+ import anyio
2
+ from curl_cffi import requests
3
+ from curl_cffi.requests.exceptions import SessionClosed
4
+
5
+ from tai42_kit.clients.base import PooledClient, is_loop_bound_runtime_error, reject_unknown_connection_kwargs
6
+
7
+ _ALLOWED_KWARGS = frozenset({"session_params"})
8
+
9
+
10
+ class CurlClient(PooledClient[requests.AsyncSession]):
11
+ """Manages reusable curl_cffi sessions.
12
+
13
+ Every primitive in ``session_params`` maps onto the ``AsyncSession``, giving
14
+ full control over the network layer.
15
+ """
16
+
17
+ async def _create(self, **kwargs) -> requests.AsyncSession:
18
+ reject_unknown_connection_kwargs("Curl client", kwargs, _ALLOWED_KWARGS)
19
+ # Copy before mutating — never alter the caller's dict.
20
+ session_params = {k: v for k, v in kwargs.get("session_params", {}).items() if k != "session_key"}
21
+ return requests.AsyncSession(**session_params)
22
+
23
+ async def _close(self, client: requests.AsyncSession):
24
+ if client:
25
+ await client.close()
26
+
27
+ def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
28
+ return (
29
+ # The async resource is dead (socket / loop closed)
30
+ anyio.ClosedResourceError,
31
+ anyio.BrokenResourceError,
32
+ # The curl_cffi session object is explicitly closed / invalid
33
+ SessionClosed,
34
+ )
35
+
36
+ def _is_disconnection_error(self, exc: BaseException) -> bool:
37
+ # curl_cffi/anyio surface a dead loop-bound session as a plain
38
+ # RuntimeError ("Event loop is closed" / "... attached to a different
39
+ # loop"); match those by message so an unrelated RuntimeError raised by
40
+ # caller code never tears down the pool.
41
+ return super()._is_disconnection_error(exc) or is_loop_bound_runtime_error(exc)
@@ -0,0 +1,26 @@
1
+ import httpx
2
+
3
+ from tai42_kit.clients.base import PooledClient, reject_unknown_connection_kwargs
4
+
5
+
6
+ class HttpxClient(PooledClient[httpx.AsyncClient]):
7
+ """Pooled ``httpx.AsyncClient`` for app-owned outbound HTTP.
8
+
9
+ Connection params arrive as kwargs (``timeout``, ``max_connections``,
10
+ ``max_keepalive_connections``, ``keepalive_expiry``); unspecified ones keep
11
+ httpx's defaults. ``trust_env=False`` ignores ambient proxy env vars.
12
+ """
13
+
14
+ _LIMIT_KEYS = ("max_connections", "max_keepalive_connections", "keepalive_expiry")
15
+ _ALLOWED_KWARGS = frozenset({"timeout", *_LIMIT_KEYS})
16
+
17
+ async def _create(self, **kwargs) -> httpx.AsyncClient:
18
+ reject_unknown_connection_kwargs("Httpx client", kwargs, self._ALLOWED_KWARGS)
19
+ limits = httpx.Limits(**{k: kwargs[k] for k in self._LIMIT_KEYS if k in kwargs})
20
+ client_kwargs = {"trust_env": False, "limits": limits}
21
+ if "timeout" in kwargs:
22
+ client_kwargs["timeout"] = httpx.Timeout(kwargs["timeout"])
23
+ return httpx.AsyncClient(**client_kwargs)
24
+
25
+ async def _close(self, client: httpx.AsyncClient):
26
+ await client.aclose()
@@ -0,0 +1,60 @@
1
+ import asyncio
2
+ from typing import Literal
3
+
4
+ import anyio
5
+ from fastmcp import Client
6
+ from tai42_contract.manifest import TaiMCPConfig
7
+
8
+ from tai42_kit.clients.base import PooledClient, is_loop_bound_runtime_error, reject_unknown_connection_kwargs
9
+ from tai42_kit.clients.settings import mcp_client_settings
10
+ from tai42_kit.transport import get_mcp_transport
11
+
12
+ # Connection kwargs a FastMCP client accepts. Anything else is rejected so it
13
+ # can't silently split the pool key.
14
+ _ALLOWED_KWARGS = frozenset({"config", "uds_protocol"})
15
+
16
+
17
+ class FastMCPClient(PooledClient[Client]):
18
+ async def _create(self, **kwargs) -> Client:
19
+ if "config" not in kwargs:
20
+ raise KeyError("FastMCP client requires a `config` kwarg")
21
+
22
+ config = kwargs["config"]
23
+ if not config or not isinstance(config, dict):
24
+ raise TypeError("FastMCP client `config` must be a non-empty dict")
25
+ reject_unknown_connection_kwargs("FastMCP client", kwargs, _ALLOWED_KWARGS)
26
+
27
+ # The MCP wire protocol over a UDS socket; the caller (which owns the
28
+ # runtime/CLI) supplies it. Ignored for non-UDS servers.
29
+ uds_protocol: Literal["http", "sse"] = kwargs.get("uds_protocol", "http")
30
+ transport = get_mcp_transport(TaiMCPConfig(**config), uds_protocol=uds_protocol)
31
+ # No ambient-header forwarding: fastmcp transports default
32
+ # ``forward_incoming_headers=False``, so this server's own inbound headers
33
+ # (incl. auth) are never leaked to the downstream MCP — only the headers
34
+ # declared in ``config`` are sent. The conformance tests lock this so a
35
+ # future fastmcp default-change fails loudly instead of leaking silently.
36
+ #
37
+ # Bound the connect/init: this runs inside the pooled per-key creation
38
+ # lock, so an unbounded connect-stall would queue every same-config caller
39
+ # forever. wait_for cancels the inner enter on expiry, letting fastmcp
40
+ # unwind its partial transport state; nothing is registered in the pool.
41
+ timeout = mcp_client_settings().connect_timeout_seconds
42
+ try:
43
+ return await asyncio.wait_for(Client(transport).__aenter__(), timeout=timeout)
44
+ except TimeoutError as exc:
45
+ raise TimeoutError(
46
+ f"MCP client connect/init did not complete within {timeout}s (MCP_CLIENT_CONNECT_TIMEOUT_SECONDS)"
47
+ ) from exc
48
+
49
+ async def _close(self, client: Client):
50
+ await client.__aexit__(None, None, None)
51
+
52
+ def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
53
+ return anyio.ClosedResourceError, anyio.BrokenResourceError
54
+
55
+ def _is_disconnection_error(self, exc: BaseException) -> bool:
56
+ # anyio/asyncio surface a dead loop-bound transport as a plain
57
+ # RuntimeError ("Event loop is closed" / "... attached to a different
58
+ # loop"); match those by message so an unrelated RuntimeError raised by
59
+ # caller code never tears down the pool.
60
+ return super()._is_disconnection_error(exc) or is_loop_bound_runtime_error(exc)
@@ -0,0 +1,58 @@
1
+ from psycopg import AsyncConnection
2
+ from psycopg.rows import TupleRow
3
+ from psycopg.types.json import Json
4
+ from psycopg_pool import AsyncConnectionPool
5
+
6
+ from tai42_kit.clients.base import PooledClient, reject_unknown_connection_kwargs
7
+
8
+ # ``Json`` is re-exported so callers wrap jsonb params through the pooled
9
+ # client's own module rather than importing psycopg directly — every pg
10
+ # primitive the app touches is reached through the kit client layer.
11
+ __all__ = ["Json", "PostgresClient"]
12
+
13
+ # The pool's default connection type. Naming it lets the pooled-client generic
14
+ # resolve concretely (the AsyncConnectionPool default hides ACT behind a cast).
15
+ _Pool = AsyncConnectionPool[AsyncConnection[TupleRow]]
16
+
17
+ # Connection kwargs a Postgres client accepts — the identity produced by
18
+ # ``PostgresConnectionSettings.client_kwargs``. Anything else is rejected so it
19
+ # can't silently split the pool key.
20
+ _ALLOWED_KWARGS = frozenset({"dsn", "min_size", "max_size"})
21
+
22
+
23
+ class PostgresClient(PooledClient[_Pool]):
24
+ """Pooled ``psycopg_pool.AsyncConnectionPool``, one pool per (DSN, size) per
25
+ loop. Compose ``PostgresConnectionSettings`` for the connection kwargs."""
26
+
27
+ async def _create(self, **kwargs) -> _Pool:
28
+ if "dsn" not in kwargs:
29
+ raise KeyError("Postgres client requires a 'dsn' kwarg")
30
+ dsn = kwargs["dsn"]
31
+ if not dsn or not isinstance(dsn, str):
32
+ raise ValueError("Postgres client 'dsn' must be a non-empty string")
33
+ reject_unknown_connection_kwargs("Postgres client", kwargs, _ALLOWED_KWARGS)
34
+
35
+ pool = AsyncConnectionPool(
36
+ conninfo=dsn,
37
+ min_size=kwargs.get("min_size", 2),
38
+ max_size=kwargs.get("max_size", 10),
39
+ open=False,
40
+ connection_class=AsyncConnection,
41
+ # Validate each connection on checkout: a connection the server has
42
+ # since closed (idle drop, restart, a severed-then-restored link) is
43
+ # discarded and replaced instead of handed to the caller, who would
44
+ # otherwise get an OperationalError on the first query. This is what
45
+ # lets a pooled caller ride out a transient Postgres outage without a
46
+ # restart.
47
+ check=AsyncConnectionPool.check_connection,
48
+ )
49
+ await pool.open()
50
+ return pool
51
+
52
+ async def _close(self, client: _Pool):
53
+ await client.close()
54
+
55
+ def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
56
+ import psycopg
57
+
58
+ return (psycopg.OperationalError,)
@@ -0,0 +1,120 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterator, Awaitable, Mapping
3
+ from typing import Any, cast
4
+
5
+ from redis import Redis as SyncRedis
6
+ from redis.asyncio import Redis as AsyncRedis
7
+
8
+ from tai42_kit.clients.base import PooledClient, reject_unknown_connection_kwargs
9
+
10
+ # Connection kwargs a Redis client accepts — the JSON-serializable identity
11
+ # produced by ``RedisConnectionSettings.client_kwargs``. Anything else is a typo
12
+ # or a stray value and is rejected so it can't silently split the pool key.
13
+ _ALLOWED_KWARGS = frozenset(
14
+ {
15
+ "url",
16
+ "max_connections",
17
+ "decode_responses",
18
+ "socket_timeout",
19
+ "socket_connect_timeout",
20
+ "retry_on_timeout",
21
+ "retry_attempts",
22
+ }
23
+ )
24
+
25
+
26
+ def _validate_kwargs(kwargs: dict[str, Any]) -> None:
27
+ _validate_url(kwargs)
28
+ reject_unknown_connection_kwargs("Redis client", kwargs, _ALLOWED_KWARGS)
29
+
30
+
31
+ def _validate_url(kwargs: dict[str, Any]) -> None:
32
+ if "url" not in kwargs:
33
+ raise KeyError("Redis client requires a 'url' kwarg")
34
+ url = kwargs["url"]
35
+ if not url:
36
+ raise ValueError("Redis client 'url' cannot be empty")
37
+ if not isinstance(url, str):
38
+ raise TypeError("Redis client 'url' must be a string (e.g., 'redis://localhost:6379/0')")
39
+
40
+
41
+ class RedisClient(PooledClient[AsyncRedis]):
42
+ async def _create(self, **kwargs) -> AsyncRedis:
43
+ _validate_kwargs(kwargs)
44
+ return await AsyncRedis.from_url(**_with_retry(kwargs)).initialize()
45
+
46
+ async def _close(self, client: AsyncRedis):
47
+ await client.aclose()
48
+
49
+ def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
50
+ from redis.exceptions import ConnectionError as RedisConnectionError
51
+ from redis.exceptions import TimeoutError as RedisTimeoutError
52
+
53
+ return RedisConnectionError, RedisTimeoutError
54
+
55
+
56
+ class SyncRedisClient(PooledClient[SyncRedis]):
57
+ async def _create(self, **kwargs) -> SyncRedis:
58
+ _validate_kwargs(kwargs)
59
+ return SyncRedis.from_url(**_with_retry(kwargs, sync=True))
60
+
61
+ async def _close(self, client: SyncRedis):
62
+ # Sync close can do network I/O to release pool connections — keep it
63
+ # off the event loop.
64
+ await asyncio.to_thread(client.close)
65
+
66
+ def _disconnection_exceptions(self) -> tuple[type[Exception], ...]:
67
+ from redis.exceptions import ConnectionError as RedisConnectionError
68
+ from redis.exceptions import TimeoutError as RedisTimeoutError
69
+
70
+ return RedisConnectionError, RedisTimeoutError
71
+
72
+
73
+ def _with_retry(kwargs: dict[str, Any], *, sync: bool = False) -> dict[str, Any]:
74
+ """Turn the serializable ``retry_attempts`` int into a redis ``Retry`` object.
75
+
76
+ ``retry_attempts`` is part of the pool key (a stable int) but is not a valid
77
+ ``Redis.from_url`` argument, so it is consumed here and, when positive,
78
+ materialized into an exponential-backoff ``Retry`` on connection/timeout
79
+ errors — built per client because a ``Retry`` instance is not serializable.
80
+ The sync and async clients import ``Retry`` from their own subpackage.
81
+ """
82
+ retry_attempts = kwargs.pop("retry_attempts", 0)
83
+ if retry_attempts > 0:
84
+ if sync:
85
+ from redis.retry import Retry
86
+ else:
87
+ from redis.asyncio.retry import Retry
88
+ from redis.backoff import ExponentialBackoff
89
+ from redis.exceptions import ConnectionError as RedisConnectionError
90
+ from redis.exceptions import TimeoutError as RedisTimeoutError
91
+
92
+ kwargs["retry"] = Retry(ExponentialBackoff(), retry_attempts)
93
+ kwargs["retry_on_error"] = [RedisConnectionError, RedisTimeoutError]
94
+ return kwargs
95
+
96
+
97
+ # Typed seams over redis-py 7.x async client stubs.
98
+ #
99
+ # redis-py 7.x annotates command methods with the shared sync/async signature
100
+ # (``Awaitable[T] | T``), so ``await client.hgetall(...)`` fails type checking
101
+ # even though the async client always returns an awaitable at runtime. These
102
+ # helpers pin the async half of the hash commands, confining the required casts
103
+ # to one place; they add no runtime behavior beyond a plain function call.
104
+
105
+
106
+ def hgetall(client: AsyncRedis, key: str) -> Awaitable[dict[str, str]]:
107
+ """``client.hgetall(key)`` pinned to the async client's true awaitable return
108
+ (decoded-responses shape: a field/value string map, ``{}`` for a missing key)."""
109
+ return cast("Awaitable[dict[str, str]]", client.hgetall(key))
110
+
111
+
112
+ def hset_mapping(client: AsyncRedis, key: str, mapping: Mapping[str, str]) -> Awaitable[int]:
113
+ """``client.hset(key, mapping=mapping)`` pinned to the async client's true
114
+ awaitable return (the count of newly added fields)."""
115
+ return cast("Awaitable[int]", client.hset(key, mapping=dict(mapping)))
116
+
117
+
118
+ def scan_iter(client: AsyncRedis, match: str) -> AsyncIterator[Any]:
119
+ """``client.scan_iter(match=match)`` pinned to the async client's iterator."""
120
+ return cast("AsyncIterator[Any]", client.scan_iter(match=match))