hotdata-materialized 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.
@@ -0,0 +1,44 @@
1
+ """hotdata-materialized: offload expensive Django query results to Hotdata.
2
+
3
+ Public surface: the @materialize decorator and MaterializedFrame handle,
4
+ built on fingerprinting, the remote registry, and the entry store.
5
+ """
6
+
7
+ from .conf import Config
8
+ from .client import HotdataClients, get_clients, reset_clients
9
+ from .decorator import MaterializedFrame, materialize
10
+ from .exceptions import (
11
+ ConfigurationError,
12
+ FingerprintError,
13
+ MaterializedError,
14
+ RegistryError,
15
+ StoreError,
16
+ )
17
+ from .fingerprint import fingerprint_call, fingerprint_queryset
18
+ from .indexes import BM25, Vector
19
+ from .registry import Registry, RegistryEntry
20
+ from .store import EntryStore
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = [
25
+ "materialize",
26
+ "MaterializedFrame",
27
+ "BM25",
28
+ "Vector",
29
+ "Config",
30
+ "HotdataClients",
31
+ "get_clients",
32
+ "reset_clients",
33
+ "MaterializedError",
34
+ "ConfigurationError",
35
+ "FingerprintError",
36
+ "RegistryError",
37
+ "StoreError",
38
+ "fingerprint_call",
39
+ "fingerprint_queryset",
40
+ "Registry",
41
+ "RegistryEntry",
42
+ "EntryStore",
43
+ "__version__",
44
+ ]
@@ -0,0 +1,44 @@
1
+ """Arrow/parquet encoding helpers shared by the registry and the entry store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import json
8
+ from typing import Optional
9
+
10
+ import pyarrow as pa
11
+ import pyarrow.ipc as pa_ipc
12
+ import pyarrow.parquet as pq
13
+
14
+
15
+ def table_to_parquet_bytes(table: pa.Table) -> bytes:
16
+ buffer = io.BytesIO()
17
+ pq.write_table(table, buffer)
18
+ return buffer.getvalue()
19
+
20
+
21
+ def table_to_ipc_bytes(table: pa.Table) -> bytes:
22
+ sink = io.BytesIO()
23
+ with pa_ipc.new_stream(sink, table.schema) as writer:
24
+ writer.write_table(table)
25
+ return sink.getvalue()
26
+
27
+
28
+ def encode_inline_payload(table: pa.Table, threshold_bytes: int) -> Optional[str]:
29
+ # threshold bounds the payload as stored (post-base64), not the raw IPC
30
+ payload = base64.b64encode(table_to_ipc_bytes(table)).decode("ascii")
31
+ if len(payload) > threshold_bytes:
32
+ return None
33
+ return payload
34
+
35
+
36
+ def decode_inline_payload(payload: str) -> pa.Table:
37
+ with pa_ipc.open_stream(io.BytesIO(base64.b64decode(payload))) as reader:
38
+ return reader.read_all()
39
+
40
+
41
+ def schema_to_json(table: pa.Table) -> str:
42
+ return json.dumps(
43
+ [{"name": field.name, "type": str(field.type)} for field in table.schema]
44
+ )
@@ -0,0 +1,42 @@
1
+ """SQL literal and identifier encoding.
2
+
3
+ POST /v1/queries takes SQL text only — there are no bind parameters — so
4
+ every value written into SQL must go through quote_literal(), and every
5
+ caller-supplied identifier (column names in search) through quote_ident().
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime as _dt
11
+ import math
12
+ import re
13
+
14
+ from .exceptions import RegistryError
15
+
16
+ _IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
17
+
18
+
19
+ def quote_ident(name: str) -> str:
20
+ if not _IDENT.match(name):
21
+ raise ValueError(f"invalid SQL identifier: {name!r}")
22
+ return name
23
+
24
+
25
+ def quote_literal(value) -> str:
26
+ if value is None:
27
+ return "NULL"
28
+ if isinstance(value, bool):
29
+ return "TRUE" if value else "FALSE"
30
+ if isinstance(value, int):
31
+ return str(value)
32
+ if isinstance(value, float):
33
+ if math.isnan(value) or math.isinf(value):
34
+ raise RegistryError(f"cannot encode non-finite float: {value!r}")
35
+ return repr(value)
36
+ if isinstance(value, (_dt.datetime, _dt.date)):
37
+ return quote_literal(value.isoformat())
38
+ if isinstance(value, str):
39
+ if "\x00" in value:
40
+ raise RegistryError("cannot encode string containing NUL byte")
41
+ return "'" + value.replace("'", "''") + "'"
42
+ raise RegistryError(f"cannot encode SQL literal of type {type(value).__name__}")
@@ -0,0 +1,50 @@
1
+ """Hotdata SDK client construction.
2
+
3
+ One ApiClient (and its connection pool) per process, shared by the query,
4
+ databases, and uploads resource APIs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from typing import Optional
11
+
12
+ import hotdata
13
+ from hotdata.arrow import ResultsApi
14
+ from hotdata.query import QueryApi
15
+ from hotdata.uploads import UploadsApi
16
+
17
+ from .conf import Config
18
+
19
+
20
+ class HotdataClients:
21
+ def __init__(self, config: Config):
22
+ sdk_config = hotdata.Configuration(
23
+ host=config.api_url,
24
+ api_key=config.api_key,
25
+ workspace_id=config.workspace_id,
26
+ )
27
+ self.api_client = hotdata.ApiClient(sdk_config)
28
+ self.query = QueryApi(self.api_client)
29
+ self.databases = hotdata.DatabasesApi(self.api_client)
30
+ self.uploads = UploadsApi(self.api_client)
31
+ self.results = ResultsApi(self.api_client)
32
+ self.indexes = hotdata.IndexesApi(self.api_client)
33
+
34
+
35
+ _lock = threading.Lock()
36
+ _clients: Optional[HotdataClients] = None
37
+
38
+
39
+ def get_clients(config: Optional[Config] = None) -> HotdataClients:
40
+ global _clients
41
+ with _lock:
42
+ if _clients is None:
43
+ _clients = HotdataClients(config or Config.from_django())
44
+ return _clients
45
+
46
+
47
+ def reset_clients() -> None:
48
+ global _clients
49
+ with _lock:
50
+ _clients = None
@@ -0,0 +1,70 @@
1
+ """Configuration, loaded from the HOTDATA_MATERIALIZED dict in Django settings.
2
+
3
+ The Config dataclass is plain and injectable so the library (and its tests)
4
+ can run without a configured Django project.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Dict
11
+
12
+ from .exceptions import ConfigurationError
13
+
14
+ DEFAULT_API_URL = "https://api.hotdata.dev"
15
+
16
+ _REQUIRED_KEYS = ("API_KEY", "WORKSPACE_ID")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Config:
21
+ api_key: str
22
+ workspace_id: str
23
+ api_url: str = DEFAULT_API_URL
24
+ registry_database: str = "materialized_registry"
25
+ inline_threshold_bytes: int = 2 * 1024 * 1024
26
+ entry_prefix: str = "mz_"
27
+ # Server-side entry-database expiry is set to ttl + grace: a safety net
28
+ # behind the sweep, never the primary eviction path.
29
+ entry_expiry_grace_seconds: int = 24 * 3600
30
+ # Write-behind is the default: a miss returns as soon as the caller's data
31
+ # is in hand and the persist runs on a small background pool.
32
+ background: bool = True
33
+ background_max_workers: int = 2
34
+
35
+ @classmethod
36
+ def from_dict(cls, raw: Dict[str, Any]) -> "Config":
37
+ missing = [key for key in _REQUIRED_KEYS if not raw.get(key)]
38
+ if missing:
39
+ raise ConfigurationError(
40
+ "HOTDATA_MATERIALIZED is missing required keys: " + ", ".join(missing)
41
+ )
42
+ return cls(
43
+ api_key=raw["API_KEY"],
44
+ workspace_id=raw["WORKSPACE_ID"],
45
+ api_url=raw.get("API_URL", DEFAULT_API_URL),
46
+ registry_database=raw.get("REGISTRY_DATABASE", cls.registry_database),
47
+ inline_threshold_bytes=raw.get(
48
+ "INLINE_THRESHOLD_BYTES", cls.inline_threshold_bytes
49
+ ),
50
+ entry_prefix=raw.get("ENTRY_PREFIX", cls.entry_prefix),
51
+ entry_expiry_grace_seconds=raw.get(
52
+ "ENTRY_EXPIRY_GRACE_SECONDS", cls.entry_expiry_grace_seconds
53
+ ),
54
+ background=raw.get("BACKGROUND", cls.background),
55
+ background_max_workers=raw.get(
56
+ "BACKGROUND_MAX_WORKERS", cls.background_max_workers
57
+ ),
58
+ )
59
+
60
+ @classmethod
61
+ def from_django(cls) -> "Config":
62
+ from django.conf import settings
63
+
64
+ raw = getattr(settings, "HOTDATA_MATERIALIZED", None)
65
+ if not isinstance(raw, dict):
66
+ raise ConfigurationError(
67
+ "Define a HOTDATA_MATERIALIZED dict in Django settings "
68
+ "(required keys: API_KEY, WORKSPACE_ID)."
69
+ )
70
+ return cls.from_dict(raw)
@@ -0,0 +1,237 @@
1
+ """The @materialize decorator and the MaterializedFrame handle.
2
+
3
+ Wrap an expensive function with @materialize: a hit returns a frame backed by
4
+ Hotdata without calling the function; a miss calls it, returns a frame over
5
+ the computed result immediately, and persists write-behind. The wrapped
6
+ function must return something table-shaped: a pyarrow.Table, a pandas
7
+ DataFrame, or an iterable of dicts (e.g. a Django .values() queryset).
8
+
9
+ Fail-open: if the hit check or the persist raises a MaterializedError, the
10
+ function runs and its result is served uncached — the cache can degrade to
11
+ "no cache," never to "no page."
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import logging
18
+ import re
19
+ import threading
20
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
21
+
22
+ import pyarrow as pa
23
+
24
+ from .client import get_clients
25
+ from .conf import Config
26
+ from .exceptions import MaterializedError
27
+ from ._sql import quote_ident, quote_literal
28
+ from .fingerprint import fingerprint_call
29
+ from .indexes import BM25, Vector
30
+ from .registry import STATUS_READY, Registry, RegistryEntry
31
+ from .store import DATA_SCHEMA, DATA_TABLE, EntryStore
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _THIS = re.compile(r"\bthis\b")
36
+ # quoted chunks ('...' with '' escapes, "..." with "" escapes) pass through
37
+ # untouched so a literal like WHERE label = 'this' is never rewritten
38
+ _SQL_CHUNKS = re.compile(r"('(?:[^']|'')*'|\"(?:[^\"]|\"\")*\")")
39
+
40
+
41
+ def _rewrite_this(sql: str) -> str:
42
+ return "".join(
43
+ part if part.startswith(("'", '"')) else _THIS.sub(DATA_TABLE, part)
44
+ for part in _SQL_CHUNKS.split(sql)
45
+ )
46
+
47
+
48
+ class MaterializedFrame:
49
+ """Handle over a materialized entry. One interface, two backings: a fresh
50
+ miss wraps the locally computed table; a hit reads from Hotdata lazily."""
51
+
52
+ def __init__(
53
+ self,
54
+ store: EntryStore,
55
+ entry: RegistryEntry,
56
+ table: Optional[pa.Table] = None,
57
+ *,
58
+ cached: bool,
59
+ ):
60
+ self._store = store
61
+ self.entry = entry
62
+ self._table = table
63
+ self.cached = cached
64
+
65
+ def arrow(self) -> pa.Table:
66
+ if self._table is None:
67
+ self._table = self._store.read_table(self.entry)
68
+ return self._table
69
+
70
+ def to_pylist(self) -> List[Dict[str, Any]]:
71
+ return self.arrow().to_pylist()
72
+
73
+ def df(self) -> Any:
74
+ """The data as a pandas.DataFrame (requires pandas)."""
75
+ return self.arrow().to_pandas()
76
+
77
+ def sql(self, sql: str) -> pa.Table:
78
+ """Run SQL server-side against this entry's database; the identifier
79
+ `this` names the cached data (e.g. "SELECT x FROM this LIMIT 5").
80
+
81
+ Requires a persisted entry: available on hits, or after the
82
+ write-behind persist lands (a fresh miss raises StoreError until
83
+ then; use background=False on the decorator to persist inline)."""
84
+ return self._store.query_table(self.entry, _rewrite_this(sql))
85
+
86
+ def search(self, query: str, *, column: str, limit: int = 10) -> pa.Table:
87
+ """BM25 keyword search over the entry, best matches first. Requires
88
+ a BM25 index on the column (declare with @materialize(index=BM25(...));
89
+ a freshly missed entry may error until the index build finishes)."""
90
+ table_ref = f"default.{DATA_SCHEMA}.{DATA_TABLE}"
91
+ return self._store.query_table(
92
+ self.entry,
93
+ f"SELECT * FROM bm25_search({quote_literal(table_ref)}, "
94
+ f"{quote_literal(quote_ident(column))}, {quote_literal(query)}) "
95
+ f"ORDER BY score DESC LIMIT {int(limit)}",
96
+ )
97
+
98
+ def vector_search(self, query: str, *, column: str, limit: int = 10) -> pa.Table:
99
+ """Semantic search over the entry, nearest first. Requires a vector
100
+ index (declare with @materialize(index=Vector(...))). Pass the indexed
101
+ source column; the query text is embedded server-side."""
102
+ return self._store.query_table(
103
+ self.entry,
104
+ f"SELECT *, vector_distance({quote_ident(column)}, "
105
+ f"{quote_literal(query)}) AS distance "
106
+ f"FROM {DATA_TABLE} ORDER BY distance LIMIT {int(limit)}",
107
+ )
108
+
109
+ def __len__(self) -> int:
110
+ return self.arrow().num_rows
111
+
112
+
113
+ _runtime_lock = threading.Lock()
114
+ _runtime: Optional[Tuple[Registry, EntryStore]] = None
115
+
116
+
117
+ def get_runtime() -> Tuple[Registry, EntryStore]:
118
+ """The process-wide (Registry, EntryStore) pair, built from Django
119
+ settings on first use."""
120
+ global _runtime
121
+ with _runtime_lock:
122
+ if _runtime is None:
123
+ config = Config.from_django()
124
+ clients = get_clients(config)
125
+ registry = Registry(clients, config)
126
+ _runtime = (registry, EntryStore(clients, config, registry))
127
+ return _runtime
128
+
129
+
130
+ def reset_runtime() -> None:
131
+ global _runtime
132
+ with _runtime_lock:
133
+ _runtime = None
134
+
135
+
136
+ def to_arrow(result: Any) -> pa.Table:
137
+ """Normalize a captured result into a pyarrow.Table."""
138
+ if isinstance(result, pa.Table):
139
+ return result
140
+ if type(result).__module__.split(".")[0] == "pandas":
141
+ return pa.Table.from_pandas(result)
142
+ rows = list(result)
143
+ if rows and not isinstance(rows[0], dict):
144
+ raise TypeError(
145
+ "materialize expects a pyarrow.Table, a pandas.DataFrame, or an "
146
+ "iterable of dicts (e.g. a .values() queryset); got an iterable "
147
+ f"of {type(rows[0]).__name__}"
148
+ )
149
+ return pa.Table.from_pylist(rows)
150
+
151
+
152
+ def materialize(
153
+ key: Optional[str] = None,
154
+ *,
155
+ ttl: Optional[int] = 3600,
156
+ version: int = 0,
157
+ background: Optional[bool] = None,
158
+ key_fn: Optional[Callable[..., Any]] = None,
159
+ index: Union[BM25, Vector, Sequence[Union[BM25, Vector]], None] = None,
160
+ ) -> Callable[[Callable[..., Any]], Callable[..., MaterializedFrame]]:
161
+ """Materialize a function's result into Hotdata.
162
+
163
+ @materialize(ttl=3600)
164
+ def revenue_by_region():
165
+ return Order.objects.values("region").annotate(revenue=Sum("total"))
166
+
167
+ The call returns a MaterializedFrame either way; `.cached` says which path
168
+ served it. `version=` busts the cache on code changes; `key_fn=` maps
169
+ non-JSON-serializable arguments to a stable identity. `index=` declares
170
+ BM25/Vector search indexes built when the entry persists.
171
+ """
172
+ declarations: Tuple[Union[BM25, Vector], ...]
173
+ if index is None:
174
+ declarations = ()
175
+ elif isinstance(index, (BM25, Vector)):
176
+ declarations = (index,)
177
+ else:
178
+ declarations = tuple(index)
179
+ embedded = any(
180
+ isinstance(d, Vector) and d.provider is not None for d in declarations
181
+ )
182
+ if embedded and len(declarations) > 1:
183
+ # platform constraint: embedding-backed vector indexes cannot coexist
184
+ # with other indexes on the same table
185
+ raise ValueError(
186
+ "an embedding-backed Vector index (provider=...) cannot be "
187
+ "combined with other indexes on the same entry"
188
+ )
189
+
190
+ def decorate(func: Callable[..., Any]) -> Callable[..., MaterializedFrame]:
191
+ @functools.wraps(func)
192
+ def wrapper(*args: Any, **kwargs: Any) -> MaterializedFrame:
193
+ registry, store = get_runtime()
194
+ fingerprint = fingerprint_call(
195
+ func, args, kwargs, version=version, key_fn=key_fn
196
+ )
197
+ entry = None
198
+ try:
199
+ entry = registry.lookup(fingerprint)
200
+ except MaterializedError:
201
+ logger.warning(
202
+ "hit check for %s failed; running uncached",
203
+ func.__qualname__,
204
+ exc_info=True,
205
+ )
206
+ if (
207
+ entry is not None
208
+ and entry.status == STATUS_READY
209
+ and not entry.is_expired(store._now())
210
+ ):
211
+ return MaterializedFrame(store, entry, cached=True)
212
+
213
+ result = func(*args, **kwargs)
214
+ table = to_arrow(result)
215
+ label = key or func.__qualname__
216
+ try:
217
+ pending = store.materialize(
218
+ fingerprint,
219
+ table,
220
+ key=label,
221
+ ttl=ttl,
222
+ version=version,
223
+ background=background,
224
+ indexes=declarations,
225
+ )
226
+ except MaterializedError:
227
+ logger.warning(
228
+ "persist of %s failed; serving the result uncached",
229
+ label,
230
+ exc_info=True,
231
+ )
232
+ pending = RegistryEntry(fingerprint=fingerprint, key=label)
233
+ return MaterializedFrame(store, pending, table=table, cached=False)
234
+
235
+ return wrapper
236
+
237
+ return decorate
@@ -0,0 +1,22 @@
1
+ """Exception hierarchy. Everything raised by this package subclasses
2
+ MaterializedError so callers can catch broadly."""
3
+
4
+
5
+ class MaterializedError(Exception):
6
+ """Base class for all hotdata-materialized errors."""
7
+
8
+
9
+ class ConfigurationError(MaterializedError):
10
+ """The HOTDATA_MATERIALIZED setting is missing or incomplete."""
11
+
12
+
13
+ class FingerprintError(MaterializedError):
14
+ """A call could not be fingerprinted deterministically."""
15
+
16
+
17
+ class RegistryError(MaterializedError):
18
+ """A registry read or write failed."""
19
+
20
+
21
+ class StoreError(MaterializedError):
22
+ """Materializing or evicting an entry failed."""
@@ -0,0 +1,77 @@
1
+ """Entry fingerprinting.
2
+
3
+ A fingerprint content-addresses one materialized entry. False misses are
4
+ safe; false hits are not — so canonicalization rejects anything it cannot
5
+ serialize deterministically instead of falling back to repr(), whose output
6
+ is process-dependent for arbitrary objects.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as _dt
12
+ import decimal
13
+ import hashlib
14
+ import json
15
+ import uuid
16
+
17
+ from .exceptions import FingerprintError
18
+
19
+ SHORT_LENGTH = 16
20
+
21
+
22
+ def _stable_default(value):
23
+ # Type-tagged so date(2026, 7, 22) and the string "2026-07-22" (etc.)
24
+ # cannot fingerprint identically — a false hit serves someone else's data.
25
+ if isinstance(value, _dt.datetime):
26
+ return f"datetime:{value.isoformat()}"
27
+ if isinstance(value, _dt.date):
28
+ return f"date:{value.isoformat()}"
29
+ if isinstance(value, _dt.time):
30
+ return f"time:{value.isoformat()}"
31
+ if isinstance(value, decimal.Decimal):
32
+ return f"decimal:{value}"
33
+ if isinstance(value, uuid.UUID):
34
+ return f"uuid:{value}"
35
+ if isinstance(value, (set, frozenset)):
36
+ return sorted(value)
37
+ if isinstance(value, (bytes, bytearray)):
38
+ return f"bytes:{value.hex()}"
39
+ raise FingerprintError(
40
+ f"cannot fingerprint value of type {type(value).__name__}; "
41
+ "pass JSON-serializable arguments or supply key_fn= on the decorator"
42
+ )
43
+
44
+
45
+ def canonical_json(obj) -> str:
46
+ try:
47
+ return json.dumps(
48
+ obj, sort_keys=True, separators=(",", ":"), default=_stable_default
49
+ )
50
+ except TypeError as exc:
51
+ raise FingerprintError(str(exc)) from exc
52
+
53
+
54
+ def _digest(canonical: str) -> str:
55
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
56
+
57
+
58
+ def fingerprint_call(func, args=(), kwargs=None, *, version=0, key_fn=None) -> str:
59
+ ref = f"{func.__module__}.{func.__qualname__}"
60
+ if key_fn is not None:
61
+ call = key_fn(*args, **(kwargs or {}))
62
+ else:
63
+ call = {"args": list(args), "kwargs": kwargs or {}}
64
+ return _digest(canonical_json({"fn": ref, "version": version, "call": call}))
65
+
66
+
67
+ def fingerprint_queryset(queryset, *, version=0) -> str:
68
+ # (sql, params) from the compiler, not str(queryset.query) — the latter
69
+ # inlines parameters unreliably and is not stable across backends.
70
+ sql, params = queryset.query.get_compiler(queryset.db).as_sql()
71
+ return _digest(
72
+ canonical_json({"sql": sql, "params": list(params), "version": version})
73
+ )
74
+
75
+
76
+ def short(fingerprint: str) -> str:
77
+ return fingerprint[:SHORT_LENGTH]
@@ -0,0 +1,33 @@
1
+ """Search-index declarations for @materialize(index=...).
2
+
3
+ Indexes are created right after the entry's data loads; builds run as
4
+ background jobs on the Hotdata side, so a freshly missed entry may briefly
5
+ error on search until its index is ready.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Optional
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class BM25:
16
+ """Keyword (BM25) index over a text column; query via frame.search()."""
17
+
18
+ column: str
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Vector:
23
+ """Vector index; query via frame.vector_search().
24
+
25
+ With `provider` set (an embedding provider id), the column is treated
26
+ as text and embedded server-side; vector_search then targets the source
27
+ column and query text is embedded automatically. An embedding-backed
28
+ index cannot share a table with other indexes."""
29
+
30
+ column: str
31
+ provider: Optional[str] = None
32
+ metric: Optional[str] = None # "l2" | "cosine" | "dot"
33
+ dimensions: Optional[int] = None
File without changes