From 5ec43ea41668b62728dc68bf10c5f6332bcf71b8 Mon Sep 17 00:00:00 2001 From: Rohan Dsouza Date: Tue, 21 Jul 2026 14:13:04 +0530 Subject: [PATCH] feat: cache LangChain tool results in a Hotdata managed table LangChain caches LLM calls but has no equivalent for tool calls -- every StructuredTool invocation re-executes, even on a retry or a repeated question. HotdataToolCache + cached() fill that gap: a pluggable, persistent, queryable cache backend for any tool call (not just this package's own), keyed by tool name and arguments. Wires into make_hotdata_tools via cache=/cache_ttl= params, applied only to the two read/idempotent tools -- the mutating tools are never cached, since skipping a mutation on a cache hit would be a correctness bug. Unblocked by hotdata-framework 0.8.0's native key-based mode="upsert" on load_managed_table (shipped 2026-07-09), avoiding the full-table read-modify-write hotdata-dlt-destination has to emulate on older managed-table APIs. Bumps hotdata/hotdata-framework to >=0.8.0 (both released 0.8.0 the same day this was scoped) and adds pyarrow as a direct dependency. Verified with 15 new unit tests (fake backend round-tripping through real pyarrow parquet writes/reads) plus a live run against a real workspace: confirmed read/write fidelity, and found that a managed table with zero rows ever loaded 400s on query instead of returning empty -- handled by the existing fail-open policy in cached(). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 15 ++ README.md | 49 +++++ examples/langchain_cached_tools.py | 52 ++++++ hotdata_langchain/__init__.py | 3 + hotdata_langchain/cache.py | 289 +++++++++++++++++++++++++++++ hotdata_langchain/tools.py | 25 ++- pyproject.toml | 5 +- tests/test_cache.py | 181 ++++++++++++++++++ uv.lock | 18 +- 9 files changed, 626 insertions(+), 11 deletions(-) create mode 100644 examples/langchain_cached_tools.py create mode 100644 hotdata_langchain/cache.py create mode 100644 tests/test_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8041402..3075145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `HotdataToolCache` and `cached()` in `hotdata_langchain.cache`: cache LangChain tool + results in a Hotdata managed table, keyed by tool name and arguments. Works on any + plain function/tool, not just this package's own. `make_hotdata_tools` gains `cache` + and `cache_ttl` parameters that wire the two read-only tools (`hotdata_execute_sql`, + `hotdata_list_managed_databases`) through it; the mutating tools are never cached. + +### Changed +- Bump `hotdata-framework` to `>=0.8.0` and `hotdata` to `>=0.8.0` — both released 0.8.0 + today, adding native key-based `mode="upsert"`/`"update"`/`"delete"` loads on managed + tables (used by the new cache backend) and a per-call `key=` override on + `load_managed_table`. +- Add `pyarrow` as a direct dependency (already a transitive dependency of + `hotdata-framework`; the new cache module writes parquet directly). ## [0.2.2] - 2026-06-27 diff --git a/README.md b/README.md index 524f3df..b6e4b1a 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,60 @@ Limit how many rows are returned to the LLM. Useful for keeping responses within tools = hl.make_hotdata_tools(client, max_rows=50) ``` +## Caching tool calls + +LangChain caches LLM calls (`set_llm_cache`) but has no equivalent for tool calls — every +`StructuredTool` invocation re-executes, even when an agent retries the same query. +`HotdataToolCache` fills that gap using a Hotdata managed table as the backing store, keyed +by tool name and arguments: + +```python +from hotdata_langchain import HotdataToolCache + +cache = HotdataToolCache(client) # construct once per process and reuse +tools = hl.make_hotdata_tools(client, cache=cache) +``` + +This serves repeated calls to the read-only tools (`hotdata_execute_sql`, +`hotdata_list_managed_databases`) from the cache instead of re-running them. +`hotdata_create_managed_database` and `hotdata_load_managed_table` are never cached — +skipping a mutation on a cache hit would be a correctness bug, not caching. + +Pass `cache_ttl=timedelta(...)` to `make_hotdata_tools` to expire entries after a fixed age. + +### Caching arbitrary tools + +`cached()` works on any plain function or tool, not just this package's own — Hotdata can +act as a shared, queryable cache backend for database queries, API calls, or search +results from any part of your agent: + +```python +from hotdata_langchain.cache import cached + +cached_search = cached(my_search_function, cache=cache, tool_name="search") +``` + +### Known limitations + +- **Database-resolution race**: on first use, `HotdataToolCache` resolves-or-creates a + managed database by name. Managed-database names are not unique or identifying, so two + processes racing on first use can each create a distinct database with the same name, + silently splitting the cache. Pass `database_id=` (a database you created once, e.g. at + deploy time) to pin a specific database and avoid this entirely — recommended for any + multi-process deployment. +- **Shared-table write ceiling**: managed-table loads serialize at the per-table lock + regardless of key, so all cache writes funnel through one lock. Fine for moderate + concurrency; sharding by tool/hash-prefix is not implemented. +- **Per-instance memoization**: construct one long-lived `HotdataToolCache` per process and + reuse it — a fresh instance per call defeats memoization and re-races the database + resolution on every call. + ## Run the examples ```bash uv run python examples/langchain_basic.py uv run python examples/langchain_managed_db.py +uv run python examples/langchain_cached_tools.py ``` ## Development diff --git a/examples/langchain_cached_tools.py b/examples/langchain_cached_tools.py new file mode 100644 index 0000000..f9bbdc9 --- /dev/null +++ b/examples/langchain_cached_tools.py @@ -0,0 +1,52 @@ +"""Cache LangChain tool results in a Hotdata managed table.""" + +import time + +import hotdata_langchain as hl +from hotdata_langchain.cache import cached + + +def main() -> None: + client = hl.from_env() + + # HotdataToolCache is a pluggable cache backend for the two read/idempotent tools — + # repeated calls with the same SQL are served from a managed table instead of + # re-running the query. Construct one instance per process and reuse it. + cache = hl.HotdataToolCache(client) + tools = hl.make_hotdata_tools(client, cache=cache) + by_name = {tool.name: tool for tool in tools} + + sql_tool = by_name["hotdata_execute_sql"] + print("First call (miss, runs the query):") + print(sql_tool.invoke({"sql": "SELECT 1 AS ok"})) + + print("\nSecond call, same SQL (hit, served from the cache):") + print(sql_tool.invoke({"sql": "SELECT 1 AS ok"})) + + # cached() also works on any plain function — not just this package's own tools — + # which is the more general story: Hotdata as a cache backend for arbitrary + # LangChain tools (database queries, API calls, search results). + calls = {"n": 0} + + def slow_search(query: str) -> str: + calls["n"] += 1 + time.sleep(1) # stand in for a real API call or search request + return f"{calls['n']} result(s) for {query!r}" + + cached_search = cached(slow_search, cache=cache, tool_name="slow_search") + + print("\nArbitrary function, first call (miss, ~1s):") + start = time.monotonic() + print(cached_search("hotdata langchain")) + print(f"took {time.monotonic() - start:.2f}s") + + print("\nSame function, same query (hit, instant):") + start = time.monotonic() + print(cached_search("hotdata langchain")) + print(f"took {time.monotonic() - start:.2f}s") + + client.close() + + +if __name__ == "__main__": + main() diff --git a/hotdata_langchain/__init__.py b/hotdata_langchain/__init__.py index 1ec683c..f40ffe2 100644 --- a/hotdata_langchain/__init__.py +++ b/hotdata_langchain/__init__.py @@ -9,6 +9,7 @@ from hotdata_framework import HotdataClient, QueryResult, from_env +from hotdata_langchain.cache import HotdataToolCache, cached from hotdata_langchain.databases import ( create_managed_database, list_managed_databases_json, @@ -24,8 +25,10 @@ __all__ = [ "HotdataClient", + "HotdataToolCache", "QueryResult", "__version__", + "cached", "create_managed_database", "execute_sql_json", "from_env", diff --git a/hotdata_langchain/cache.py b/hotdata_langchain/cache.py new file mode 100644 index 0000000..88f5352 --- /dev/null +++ b/hotdata_langchain/cache.py @@ -0,0 +1,289 @@ +"""Cache LangChain tool call results in a Hotdata managed table.""" + +from __future__ import annotations + +import functools +import hashlib +import inspect +import json +import logging +import re +import tempfile +import time +from collections.abc import Callable, Mapping +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, TypeVar, cast + +import pyarrow as pa +import pyarrow.parquet as pq +from hotdata_framework import DEFAULT_SCHEMA, HotdataClient +from hotdata_framework.errors import HotdataTransientError, classify_sdk_error + +logger = logging.getLogger(__name__) + +_KEY_COLUMN = "cache_key" +_KEY_PATTERN = re.compile(r"^[0-9a-f]{64}$") + +F = TypeVar("F", bound=Callable[..., Any]) + + +class _Miss: + def __repr__(self) -> str: + return "" + + +MISS = _Miss() +"""Sentinel returned by :meth:`HotdataToolCache.get` on a miss or expiry. + +Distinct from ``None`` because a cached tool result may legitimately be ``None``. +""" + + +class HotdataToolCache: + """Store LangChain tool results in a Hotdata managed table, keyed by (tool name, args). + + Backed by a managed table with a declared key column (``cache_key``), written via + ``load_managed_table(..., mode="upsert")`` and read via ordinary SQL. Reads and writes + raise on backend failure — this class does not fail open. Callers that want cache + failures to degrade gracefully (recommended when wrapping a tool call) should catch + around :meth:`get`/:meth:`set`; see :func:`cached`. + """ + + def __init__( + self, + client: HotdataClient, + *, + database: str = "langchain_tool_cache", + database_id: str | None = None, + table: str = "tool_cache", + schema: str = DEFAULT_SCHEMA, + ttl: timedelta | None = None, + version: str = "v1", + ) -> None: + """Configure the cache backend. + + Pass ``database_id`` to pin an already-created managed database by id instead of + resolving/creating one by name. Recommended for any multi-process deployment: + managed-database names are not unique or identifying, so concurrent first-use + across processes can each create a distinct database with the same name, silently + splitting the cache. ``version`` is folded into every cache key; bump it to + invalidate all existing entries at once (e.g. after changing what a tool returns). + """ + self._client = client + self._database_name = database + self._database_id = database_id + self._table = table + self._schema = schema + self._ttl = ttl + self._version = version + self._resolved_database_id: str | None = None + + def make_key(self, tool_name: str, args: Mapping[str, Any]) -> str: + """Derive a deterministic cache key from a tool name and its bound arguments.""" + payload = json.dumps( + {"v": self._version, "tool": tool_name, "args": args}, + sort_keys=True, + default=_json_default, + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def get(self, key: str, *, ttl: timedelta | None = None) -> Any: + """Return the cached value for ``key``, or :data:`MISS` if absent or expired.""" + _validate_key(key) + self._ensure_ready() + sql = ( + f'SELECT result_json, created_at FROM "default"."{self._schema}"."{self._table}" ' + f"WHERE {_KEY_COLUMN} = '{key}'" + ) + result = self._client.execute_sql(sql, database=self._resolved_database_id) + rows = result.to_records() + if not rows: + return MISS + effective_ttl = ttl if ttl is not None else self._ttl + if effective_ttl is not None: + created_at = _parse_timestamp(rows[0]["created_at"]) + if datetime.now(timezone.utc) - created_at > effective_ttl: + return MISS + return json.loads(cast(str, rows[0]["result_json"])) + + def set(self, key: str, *, tool_name: str, args: Mapping[str, Any], result: Any) -> None: + """Store ``result`` under ``key``, upserting by ``cache_key``.""" + _validate_key(key) + self._ensure_ready() + row = pa.table( + { + _KEY_COLUMN: [key], + "tool_name": [tool_name], + "args_json": [json.dumps(dict(args), default=_json_default)], + "result_json": [json.dumps(result)], + "created_at": [datetime.now(timezone.utc)], + } + ) + with tempfile.TemporaryDirectory() as tmp_dir: + path = Path(tmp_dir) / "entry.parquet" + pq.write_table(row, path) # type: ignore[no-untyped-call] + self._client.load_managed_table( + cast(str, self._resolved_database_id), + self._table, + schema=self._schema, + file=str(path), + mode="upsert", + key=[_KEY_COLUMN], + ) + + def _ensure_ready(self) -> None: + # Memoized per instance — construct one long-lived HotdataToolCache per process + # and reuse it. A fresh instance re-resolves (and re-races, see __init__'s + # database_id note) on every construction. + if self._resolved_database_id is not None: + return + _retry_transient(self._resolve_and_declare) + + def _resolve_and_declare(self) -> None: + if self._database_id is not None: + self._resolved_database_id = self._database_id + else: + try: + db = self._client.resolve_managed_database(self._database_name) + except KeyError: + db = self._client.create_managed_database( + description=self._database_name, + schema=self._schema, + tables=[self._table], + keys={self._table: [_KEY_COLUMN]}, + ) + self._resolved_database_id = db.id + # Best-effort: declare the table if this cache's database already existed + # without it (e.g. shared with another table= value, or created concurrently + # by another process without our table). There's no dedicated "does this table + # exist" signal, so a failure here is assumed to mean it's already declared; + # any real problem (bad auth, etc.) surfaces clearly on the get/set that follows. + try: + self._client.add_managed_table( + self._resolved_database_id, + self._table, + schema=self._schema, + key=[_KEY_COLUMN], + ) + except Exception: + logger.debug( + "add_managed_table for %s.%s did not create a new table", + self._schema, + self._table, + exc_info=True, + ) + + +def cached( + fn: F, + *, + cache: HotdataToolCache, + tool_name: str, + ttl: timedelta | None = None, +) -> F: + """Wrap ``fn`` so repeated calls with the same arguments are served from ``cache``. + + Works on any plain function, not just this package's own tools — pass the name you + want it cached under via ``tool_name``. Cache backend failures never propagate: a + failed lookup is treated as a miss and a failed write is dropped, so caching can only + make a tool call faster, never break one that would otherwise succeed. + """ + if inspect.iscoroutinefunction(fn): + + @functools.wraps(fn) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + bound = _bind_args(fn, args, kwargs) + key = cache.make_key(tool_name, bound) + hit = _safe_get(cache, key, ttl) + if hit is not MISS: + return hit + result = await fn(*args, **kwargs) + _safe_set(cache, key, tool_name, bound, result) + return result + + return cast(F, async_wrapper) + + @functools.wraps(fn) + def sync_wrapper(*args: Any, **kwargs: Any) -> Any: + bound = _bind_args(fn, args, kwargs) + key = cache.make_key(tool_name, bound) + hit = _safe_get(cache, key, ttl) + if hit is not MISS: + return hit + result = fn(*args, **kwargs) + _safe_set(cache, key, tool_name, bound, result) + return result + + return cast(F, sync_wrapper) + + +def _bind_args( + fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any] +) -> dict[str, Any]: + bound = inspect.signature(fn).bind(*args, **kwargs) + bound.apply_defaults() + return dict(bound.arguments) + + +def _safe_get(cache: HotdataToolCache, key: str, ttl: timedelta | None) -> Any: + try: + return cache.get(key, ttl=ttl) + except Exception: + logger.warning("hotdata tool cache read failed; treating as a miss", exc_info=True) + return MISS + + +def _safe_set( + cache: HotdataToolCache, key: str, tool_name: str, args: dict[str, Any], result: Any +) -> None: + try: + cache.set(key, tool_name=tool_name, args=args, result=result) + except Exception: + logger.warning("hotdata tool cache write failed; result was not cached", exc_info=True) + + +def _validate_key(key: str) -> None: + if not _KEY_PATTERN.fullmatch(key): + raise ValueError(f"invalid cache key: {key!r}") + + +def _json_default(value: Any) -> Any: + model_dump = getattr(value, "model_dump", None) + if callable(model_dump): + try: + return model_dump(mode="json") + except TypeError: + pass + return str(value) + + +def _parse_timestamp(value: Any) -> datetime: + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + text = str(value) + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + parsed = datetime.fromisoformat(text) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +def _retry_transient(fn: Callable[[], None], *, attempts: int = 3, base_delay: float = 0.5) -> None: + for attempt in range(attempts): + try: + fn() + return + except Exception as exc: + if attempt == attempts - 1 or not _is_transient(exc): + raise + time.sleep(base_delay * (attempt + 1)) + + +def _is_transient(exc: BaseException) -> bool: + cause = exc.__cause__ + if not isinstance(cause, Exception): + return False + try: + return isinstance(classify_sdk_error(cause), HotdataTransientError) + except Exception: + return False diff --git a/hotdata_langchain/tools.py b/hotdata_langchain/tools.py index dc0d518..b15f9e1 100644 --- a/hotdata_langchain/tools.py +++ b/hotdata_langchain/tools.py @@ -3,11 +3,13 @@ from __future__ import annotations import json +from datetime import timedelta from typing import Any from hotdata_framework import DEFAULT_SCHEMA, HotdataClient, QueryResult from langchain_core.tools import StructuredTool +from hotdata_langchain.cache import HotdataToolCache, cached from hotdata_langchain.databases import ( create_managed_database, list_managed_databases_json, @@ -41,8 +43,18 @@ def make_hotdata_tools( *, max_rows: int = 100, database: str | None = None, + cache: HotdataToolCache | None = None, + cache_ttl: timedelta | None = None, ) -> list[StructuredTool]: - """Return LangChain tools for SQL and managed database workflows.""" + """Return LangChain tools for SQL and managed database workflows. + + Pass ``cache`` to serve repeated calls to the read-only tools + (``hotdata_execute_sql``, ``hotdata_list_managed_databases``) from a + :class:`~hotdata_langchain.cache.HotdataToolCache` instead of re-running them. The + mutating tools (``hotdata_create_managed_database``, ``hotdata_load_managed_table``) + are never cached — caching a mutation and skipping it on a cache hit would be a + correctness bug, not caching. + """ def hotdata_execute_sql(sql: str) -> str: """Run SQL against the Hotdata workspace and return JSON rows.""" @@ -52,6 +64,17 @@ def hotdata_list_managed_databases() -> str: """List Hotdata-managed databases in the workspace.""" return list_managed_databases_json(client) + if cache is not None: + hotdata_execute_sql = cached( + hotdata_execute_sql, cache=cache, tool_name="hotdata_execute_sql", ttl=cache_ttl + ) + hotdata_list_managed_databases = cached( + hotdata_list_managed_databases, + cache=cache, + tool_name="hotdata_list_managed_databases", + ttl=cache_ttl, + ) + def hotdata_create_managed_database( name: str, schema_name: str = DEFAULT_SCHEMA, diff --git a/pyproject.toml b/pyproject.toml index a91a533..3e747a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,9 +10,10 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } dependencies = [ - "hotdata-framework>=0.3.0", - "hotdata>=0.4.1", + "hotdata-framework>=0.8.0", + "hotdata>=0.8.0", "langchain-core>=1.0", + "pyarrow>=14", ] [dependency-groups] diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..b82e307 --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import asyncio +from datetime import timedelta +from unittest.mock import MagicMock + +import pyarrow.parquet as pq +import pytest +from hotdata_framework import ManagedDatabase, QueryResult + +from hotdata_langchain.cache import MISS, HotdataToolCache, cached +from hotdata_langchain.tools import make_hotdata_tools + + +@pytest.fixture +def fake_client(): + """A HotdataClient double backed by an in-memory dict, exercising real + serialization (pyarrow parquet write/read) on every set()/get() round trip.""" + store: dict[str, dict[str, object]] = {} + + client = MagicMock() + client.resolve_managed_database.side_effect = KeyError("not found") + client.create_managed_database.return_value = ManagedDatabase( + id="db_1", description="langchain_tool_cache", default_connection_id="conn_1" + ) + client.add_managed_table.return_value = None + + def _load_managed_table(database, table, *, schema, file, mode, key): + row = pq.read_table(file).to_pylist()[0] + store[row["cache_key"]] = row + + def _execute_sql(sql, *, database=None): + cache_key = sql.split("cache_key = '")[1].split("'")[0] + row = store.get(cache_key) + if row is None: + return QueryResult( + columns=["result_json", "created_at"], + rows=[], + row_count=0, + result_id=None, + query_run_id=None, + execution_time_ms=None, + ) + return QueryResult( + columns=["result_json", "created_at"], + rows=[[row["result_json"], row["created_at"]]], + row_count=1, + result_id=None, + query_run_id=None, + execution_time_ms=None, + ) + + client.load_managed_table.side_effect = _load_managed_table + client.execute_sql.side_effect = _execute_sql + client.store = store + return client + + +@pytest.fixture +def cache(fake_client): + return HotdataToolCache(fake_client) + + +def test_make_key_deterministic_regardless_of_dict_order(cache): + key_a = cache.make_key("t", {"a": 1, "b": 2}) + key_b = cache.make_key("t", {"b": 2, "a": 1}) + assert key_a == key_b + + +def test_make_key_differs_on_args_or_tool(cache): + base = cache.make_key("t", {"a": 1}) + assert base != cache.make_key("t", {"a": 2}) + assert base != cache.make_key("other", {"a": 1}) + + +def test_get_miss_when_absent(cache): + key = cache.make_key("t", {"a": 1}) + assert cache.get(key) is MISS + + +@pytest.mark.parametrize("value", ["a plain string", {"n": 1}, 42, [1, 2, 3], None]) +def test_set_then_get_round_trips_exact_type(cache, value): + key = cache.make_key("t", {"a": 1}) + cache.set(key, tool_name="t", args={"a": 1}, result=value) + got = cache.get(key) + assert got == value + assert type(got) is type(value) + + +def test_ttl_expiry_treated_as_miss(cache): + key = cache.make_key("t", {"a": 1}) + cache.set(key, tool_name="t", args={"a": 1}, result="cached-value") + assert cache.get(key, ttl=timedelta(seconds=-1)) is MISS + + +def test_declared_key_used_for_upsert(cache, fake_client): + key = cache.make_key("t", {"a": 1}) + cache.set(key, tool_name="t", args={"a": 1}, result="v") + _, kwargs = fake_client.load_managed_table.call_args + assert kwargs["mode"] == "upsert" + assert kwargs["key"] == ["cache_key"] + + +def test_cached_calls_underlying_fn_once_then_serves_hits(cache): + calls = {"n": 0} + + def fn(x: int) -> str: + calls["n"] += 1 + return f"value-{x}" + + wrapped = cached(fn, cache=cache, tool_name="fn") + assert wrapped(1) == "value-1" + assert wrapped(1) == "value-1" + assert wrapped(x=1) == "value-1" + assert calls["n"] == 1 + + assert wrapped(2) == "value-2" + assert calls["n"] == 2 + + +def test_cached_async_variant(cache): + calls = {"n": 0} + + async def fn(x: int) -> str: + calls["n"] += 1 + return f"value-{x}" + + wrapped = cached(fn, cache=cache, tool_name="fn") + + async def run() -> None: + assert await wrapped(1) == "value-1" + assert await wrapped(1) == "value-1" + + asyncio.run(run()) + assert calls["n"] == 1 + + +def test_cached_fails_open_on_backend_errors(): + broken_cache = MagicMock() + broken_cache.make_key.return_value = "k" + broken_cache.get.side_effect = RuntimeError("network blip") + broken_cache.set.side_effect = RuntimeError("network blip") + + calls = {"n": 0} + + def fn() -> str: + calls["n"] += 1 + return "real result" + + wrapped = cached(fn, cache=broken_cache, tool_name="fn") + assert wrapped() == "real result" + assert calls["n"] == 1 + + +def test_make_hotdata_tools_only_wraps_read_tools(mock_client): + cache_mock = MagicMock() + cache_mock.make_key.return_value = "k" + cache_mock.get.return_value = MISS + + tools = {t.name: t for t in make_hotdata_tools(mock_client, cache=cache_mock)} + + tools["hotdata_execute_sql"].invoke({"sql": "select 1"}) + tools["hotdata_list_managed_databases"].invoke({}) + assert cache_mock.make_key.call_count == 2 + assert mock_client.execute_sql.call_count == 1 + assert mock_client.list_managed_databases.call_count == 1 + + mock_client.create_managed_database.return_value = ManagedDatabase( + id="c1", description="sales", default_connection_id="conn_c1" + ) + tools["hotdata_create_managed_database"].invoke({"name": "sales", "tables": "orders"}) + # Mutating tools never consult the cache. + assert cache_mock.make_key.call_count == 2 + assert mock_client.create_managed_database.call_count == 1 + + +def test_make_hotdata_tools_without_cache_is_unaffected(mock_client): + tools = {t.name: t for t in make_hotdata_tools(mock_client)} + tools["hotdata_execute_sql"].invoke({"sql": "select 1"}) + tools["hotdata_execute_sql"].invoke({"sql": "select 1"}) + assert mock_client.execute_sql.call_count == 2 diff --git a/uv.lock b/uv.lock index f604793..b89ffa2 100644 --- a/uv.lock +++ b/uv.lock @@ -223,7 +223,7 @@ wheels = [ [[package]] name = "hotdata" -version = "0.4.1" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -231,14 +231,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/f8/397b2fa161e74abb115283fc169b2abe7455022cc53e1119f0fee80969d2/hotdata-0.4.1.tar.gz", hash = "sha256:e123d267872178f4dd6c8337ac47cf6616984685aca3aaa5ae2c590cecb1f29d", size = 147822, upload-time = "2026-06-19T15:12:36.131Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/38/30ed3d1d99413e7684672fa424baef85a347db465b90c774443320cf1cea/hotdata-0.8.0.tar.gz", hash = "sha256:cdac515ffa193ed028491e4b7abcd1b6404d4e3f96986c9c0a2b2f407db42eb6", size = 216848, upload-time = "2026-07-20T06:30:37.659Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/59/5ff00a1adce5a8b312692a7145660027340d3b4b352b4c3427045a59191f/hotdata-0.4.1-py3-none-any.whl", hash = "sha256:0a71c3d74bae06a79a05eb3f42893baf33f8229ef6f0dc735b59899048a2b7b1", size = 289951, upload-time = "2026-06-19T15:12:34.752Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0f/f2ccaeb0f910c3f8cd84a911d934704277f0d6e5a20f8b49cf0eedb2a8aa/hotdata-0.8.0-py3-none-any.whl", hash = "sha256:5d64bfc185a0e7e2bf34ebdcea50787d3418fb250dacc53e00dc74d64e99f753", size = 316923, upload-time = "2026-07-20T06:30:35.778Z" }, ] [[package]] name = "hotdata-framework" -version = "0.4.1" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hotdata" }, @@ -246,9 +246,9 @@ dependencies = [ { name = "pandas", version = "3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyarrow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/0a/3eb08d60328546d620c584ca9bde858c55d08f4a4daeaa98ebedf8b2c354/hotdata_framework-0.4.1.tar.gz", hash = "sha256:e692ee27fdb737c7fab76522efecedfa7e66d7f1d5786e99b194856daf552f9d", size = 93833, upload-time = "2026-06-27T15:21:46.422Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/24/879cbc596520023d1757eef8fd73b482404fb98e98d48a19571e8932bec2/hotdata_framework-0.8.0.tar.gz", hash = "sha256:057ebe8818298f1e8b4ef4250a4f4266514c7b51f3a4eb79425a1548885348ce", size = 100984, upload-time = "2026-07-20T07:04:50.175Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/e2/35c369229a1e5fe14daee6d8935288b8c7463aada3f7c10cc1625e347254/hotdata_framework-0.4.1-py3-none-any.whl", hash = "sha256:fa984f26874706788723d1400bab9413e29932773851c1355d97ce4d214123fa", size = 14077, upload-time = "2026-06-27T15:21:44.913Z" }, + { url = "https://files.pythonhosted.org/packages/98/cd/c5078d826a2240e6d7c6ef5f558280772ee189991af15d0332fd11c5a67c/hotdata_framework-0.8.0-py3-none-any.whl", hash = "sha256:6ba899e9015b71945bf56df873c0486382d4f11763a57afc423338bd68abb30a", size = 16330, upload-time = "2026-07-20T07:04:49.089Z" }, ] [[package]] @@ -259,6 +259,7 @@ dependencies = [ { name = "hotdata" }, { name = "hotdata-framework" }, { name = "langchain-core" }, + { name = "pyarrow" }, ] [package.dev-dependencies] @@ -270,9 +271,10 @@ dev = [ [package.metadata] requires-dist = [ - { name = "hotdata", specifier = ">=0.4.1" }, - { name = "hotdata-framework", specifier = ">=0.3.0" }, + { name = "hotdata", specifier = ">=0.8.0" }, + { name = "hotdata-framework", specifier = ">=0.8.0" }, { name = "langchain-core", specifier = ">=1.0" }, + { name = "pyarrow", specifier = ">=14" }, ] [package.metadata.requires-dev]