hotdata-materialized 0.1.0__tar.gz

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 (29) hide show
  1. hotdata_materialized-0.1.0/PKG-INFO +227 -0
  2. hotdata_materialized-0.1.0/README.md +202 -0
  3. hotdata_materialized-0.1.0/hotdata_materialized/__init__.py +44 -0
  4. hotdata_materialized-0.1.0/hotdata_materialized/_arrow.py +44 -0
  5. hotdata_materialized-0.1.0/hotdata_materialized/_sql.py +42 -0
  6. hotdata_materialized-0.1.0/hotdata_materialized/client.py +50 -0
  7. hotdata_materialized-0.1.0/hotdata_materialized/conf.py +70 -0
  8. hotdata_materialized-0.1.0/hotdata_materialized/decorator.py +237 -0
  9. hotdata_materialized-0.1.0/hotdata_materialized/exceptions.py +22 -0
  10. hotdata_materialized-0.1.0/hotdata_materialized/fingerprint.py +77 -0
  11. hotdata_materialized-0.1.0/hotdata_materialized/indexes.py +33 -0
  12. hotdata_materialized-0.1.0/hotdata_materialized/py.typed +0 -0
  13. hotdata_materialized-0.1.0/hotdata_materialized/registry.py +246 -0
  14. hotdata_materialized-0.1.0/hotdata_materialized/store.py +405 -0
  15. hotdata_materialized-0.1.0/hotdata_materialized.egg-info/PKG-INFO +227 -0
  16. hotdata_materialized-0.1.0/hotdata_materialized.egg-info/SOURCES.txt +28 -0
  17. hotdata_materialized-0.1.0/hotdata_materialized.egg-info/dependency_links.txt +1 -0
  18. hotdata_materialized-0.1.0/hotdata_materialized.egg-info/requires.txt +13 -0
  19. hotdata_materialized-0.1.0/hotdata_materialized.egg-info/top_level.txt +1 -0
  20. hotdata_materialized-0.1.0/pyproject.toml +60 -0
  21. hotdata_materialized-0.1.0/setup.cfg +7 -0
  22. hotdata_materialized-0.1.0/tests/test_conf_and_client.py +28 -0
  23. hotdata_materialized-0.1.0/tests/test_decorator.py +249 -0
  24. hotdata_materialized-0.1.0/tests/test_fingerprint.py +103 -0
  25. hotdata_materialized-0.1.0/tests/test_package.py +67 -0
  26. hotdata_materialized-0.1.0/tests/test_registry.py +134 -0
  27. hotdata_materialized-0.1.0/tests/test_registry_heal.py +34 -0
  28. hotdata_materialized-0.1.0/tests/test_sql.py +47 -0
  29. hotdata_materialized-0.1.0/tests/test_store.py +274 -0
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: hotdata-materialized
3
+ Version: 0.1.0
4
+ Summary: Materialize expensive Django query results into Hotdata: miss runs locally, hit returns a queryable frame.
5
+ Author: Hotdata
6
+ License: MIT
7
+ Project-URL: Homepage, https://hotdata.dev
8
+ Keywords: django,hotdata,cache,materialized,analytics,parquet
9
+ Classifier: Framework :: Django
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: Django>=3.2
15
+ Requires-Dist: hotdata[arrow]>=0.8
16
+ Requires-Dist: pyarrow>=14.0.1
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7.0; extra == "test"
19
+ Requires-Dist: duckdb>=1.0; extra == "test"
20
+ Requires-Dist: flake8>=4.0; extra == "test"
21
+ Requires-Dist: mypy>=1.5; extra == "test"
22
+ Requires-Dist: build>=1.0; extra == "test"
23
+ Provides-Extra: demo
24
+ Requires-Dist: psycopg[binary]>=3.1; extra == "demo"
25
+
26
+ # hotdata-materialized
27
+
28
+ Materialize expensive Django query results into [Hotdata](https://hotdata.dev).
29
+ On a cache **miss** your code runs in your environment as usual and the result
30
+ is captured into Hotdata as parquet — in the background, after your response
31
+ has already returned. On a **hit** your database is never touched: the data
32
+ comes back as a `pyarrow.Table` you can turn into a dataframe, or query with
33
+ SQL server-side.
34
+
35
+ Think materialized views, not Redis: entries are snapshots of expensive
36
+ analytical results, with a TTL lifecycle, each living in its own Hotdata
37
+ managed database. The host application needs **no migrations and no Redis** —
38
+ entry metadata lives in a small Hotdata managed database (the registry),
39
+ created on first use.
40
+
41
+ Measured against TPC-H on a Neon Postgres (see [demo/](demo/)):
42
+
43
+ | | Direct on Postgres | Miss (perceived) | Hit |
44
+ |---|---|---|---|
45
+ | Q1 pricing summary (full scan, 4 rows) | ~1.4 s | ~1.4 s | **~80 ms** |
46
+ | Revenue by part (50,000 rows) | ~2.1 s | ~2.0 s | **~260 ms** |
47
+
48
+ A miss costs the same as not caching (the persist runs write-behind); a hit
49
+ is 8–18× faster with zero load on your database.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install hotdata-materialized
55
+ ```
56
+
57
+ ```python
58
+ # settings.py
59
+ HOTDATA_MATERIALIZED = {
60
+ "API_KEY": env("HOTDATA_API_KEY"), # hd_... key from app.hotdata.dev
61
+ "WORKSPACE_ID": "work...",
62
+ # optional:
63
+ # "API_URL": "https://api.hotdata.dev",
64
+ # "REGISTRY_DATABASE": "materialized_registry",
65
+ # "INLINE_THRESHOLD_BYTES": 2 * 1024 * 1024,
66
+ # "BACKGROUND": True, # write-behind persists (default)
67
+ }
68
+ ```
69
+
70
+ ## Using it in a Django view
71
+
72
+ Wrap the expensive part in `@materialize`; call it from your view:
73
+
74
+ ```python
75
+ # reports.py
76
+ from django.db.models import Count, Sum
77
+ from hotdata_materialized import materialize
78
+
79
+ from .models import Order
80
+
81
+
82
+ @materialize(ttl=3600)
83
+ def revenue_by_region():
84
+ return (
85
+ Order.objects
86
+ .filter(status="complete")
87
+ .values("region")
88
+ .annotate(orders=Count("id"), revenue=Sum("total"))
89
+ .order_by("-revenue")
90
+ )
91
+ ```
92
+
93
+ ```python
94
+ # views.py
95
+ from django.http import JsonResponse
96
+ from .reports import revenue_by_region
97
+
98
+
99
+ def revenue_view(request):
100
+ frame = revenue_by_region()
101
+ return JsonResponse({"rows": frame.to_pylist(), "cached": frame.cached})
102
+ ```
103
+
104
+ On a **hit** the function never runs and your database is never touched. On a
105
+ **miss** the function runs exactly as it would uncached, the caller gets the
106
+ result immediately, and the persist happens in a background thread. Either
107
+ way you get a `MaterializedFrame`:
108
+
109
+ - `frame.arrow()` — the data as a `pyarrow.Table` (`frame.df()` for pandas,
110
+ `frame.to_pylist()` for dicts, `len(frame)` for the row count)
111
+ - `frame.sql("SELECT region, revenue FROM this ORDER BY revenue DESC LIMIT 5")`
112
+ — SQL runs server-side against the cached entry; `this` names the data.
113
+ Needs a persisted entry: available on hits (on a fresh miss the
114
+ write-behind persist has to land first; `background=False` avoids that)
115
+ - `frame.cached` — which path served it; `frame.entry` — the registry record
116
+
117
+ The wrapped function can return an iterable of dicts (a `.values()` queryset),
118
+ a `pyarrow.Table`, or a pandas DataFrame. Decorator knobs: `ttl=` (seconds,
119
+ `None` = never expires), `version=` (bump to bust the cache on code changes),
120
+ `key=` (human-readable label), `key_fn=` (stable identity for arguments that
121
+ aren't JSON-serializable), `background=False` (block until persisted).
122
+ Fail-open by design: if Hotdata is unreachable, the function runs and its
123
+ result is served uncached — the cache degrades to "no cache," never to
124
+ "no page."
125
+
126
+ ## Search
127
+
128
+ Declare a search index on the decorator and the entry gets it when it
129
+ persists (builds run server-side; a freshly missed entry may error on search
130
+ for a few seconds until its index is ready):
131
+
132
+ ```python
133
+ from hotdata_materialized import BM25, Vector, materialize
134
+
135
+
136
+ @materialize(ttl=3600, index=BM25("notes"))
137
+ def incidents():
138
+ return Incident.objects.values("id", "notes", "severity")
139
+
140
+
141
+ incidents().search("checkout errors outage", column="notes", limit=5)
142
+ ```
143
+
144
+ Vector (semantic) search embeds your text server-side via a workspace
145
+ embedding provider — pass the indexed source column and a natural-language
146
+ query:
147
+
148
+ ```python
149
+ @materialize(ttl=3600, index=Vector("notes", provider="emb_...", metric="cosine"))
150
+ def incidents_semantic():
151
+ return Incident.objects.values("id", "notes")
152
+
153
+
154
+ incidents_semantic().vector_search("the site was down", column="notes", limit=5)
155
+ ```
156
+
157
+ Both return the matching rows as a `pyarrow.Table` with a relevance column
158
+ (`score` / `distance`). One platform rule to know: an embedding-backed vector
159
+ index cannot share an entry with other indexes — the decorator rejects that
160
+ combination up front.
161
+
162
+ ## Evicting entries
163
+
164
+ The decorator composes public primitives (`fingerprint_call` /
165
+ `fingerprint_queryset`, `Registry`, `EntryStore`) that you can use directly —
166
+ for example to drop an entry explicitly:
167
+
168
+ ```python
169
+ from hotdata_materialized import fingerprint_call
170
+ from hotdata_materialized.decorator import get_runtime
171
+
172
+ registry, store = get_runtime()
173
+ store.evict(fingerprint_call(revenue_by_region))
174
+ ```
175
+
176
+ Expired entries stop being served immediately (the `is_expired` check) and
177
+ their databases carry a server-side expiry backstop; a sweep command that
178
+ deletes them proactively is on the roadmap below.
179
+
180
+ ## How it works
181
+
182
+ One managed database (the **registry**) holds one row per entry: fingerprint,
183
+ status, TTL, the entry's database id, and — for small results — the data
184
+ itself as an inline Arrow payload, so a chart-sized hit is served in a single
185
+ API round trip. Larger results live in a per-entry managed database and are
186
+ fetched as an Arrow IPC stream via a result id minted at persist time (no
187
+ re-query on hits). Failures are loud and fail toward "no cache": a failed
188
+ persist leaves no registry row, and the next request simply misses again.
189
+
190
+ See [DESIGN.md](DESIGN.md) for the architecture and the accepted
191
+ trade-offs.
192
+
193
+ ## Roadmap
194
+
195
+ - [x] Content-addressed fingerprinting (querysets and function calls)
196
+ - [x] Remote registry — no migrations or local state in the host app
197
+ - [x] Entry store with write-behind persists
198
+ - [x] Arrow-native reads (inline payloads, persisted-result fetch)
199
+ - [x] `@materialize` decorator and `MaterializedFrame`
200
+ (`.arrow()/.df()/.to_pylist()/.sql()`)
201
+ - [x] TPC-H demo and benchmark
202
+ - [x] CI: pytest, mypy, flake8
203
+ - [ ] Stale-while-revalidate refresh (rebuild protocol)
204
+ - [ ] Sweep command for expired entries
205
+ - [ ] Chainable queryset facade on the frame (`.filter()/.order_by()`)
206
+ - [x] Vector/BM25 index declarations, `frame.search()` and
207
+ `frame.vector_search()`
208
+ - [ ] Async view support (`await frame.aarrow()`)
209
+ - [ ] PyPI release
210
+
211
+ ## Demo
212
+
213
+ [demo/](demo/) is a small Django project that benchmarks TPC-H queries on a
214
+ Neon Postgres directly vs. through the cache:
215
+
216
+ ```bash
217
+ cd demo
218
+ python manage.py compare # Q1: tiny result, inline hit
219
+ python manage.py compare --scenario parts # 50k rows: remote Arrow hit
220
+ ```
221
+
222
+ ## Development
223
+
224
+ ```bash
225
+ uv venv && uv pip install -e '.[test]'
226
+ uv run pytest
227
+ ```
@@ -0,0 +1,202 @@
1
+ # hotdata-materialized
2
+
3
+ Materialize expensive Django query results into [Hotdata](https://hotdata.dev).
4
+ On a cache **miss** your code runs in your environment as usual and the result
5
+ is captured into Hotdata as parquet — in the background, after your response
6
+ has already returned. On a **hit** your database is never touched: the data
7
+ comes back as a `pyarrow.Table` you can turn into a dataframe, or query with
8
+ SQL server-side.
9
+
10
+ Think materialized views, not Redis: entries are snapshots of expensive
11
+ analytical results, with a TTL lifecycle, each living in its own Hotdata
12
+ managed database. The host application needs **no migrations and no Redis** —
13
+ entry metadata lives in a small Hotdata managed database (the registry),
14
+ created on first use.
15
+
16
+ Measured against TPC-H on a Neon Postgres (see [demo/](demo/)):
17
+
18
+ | | Direct on Postgres | Miss (perceived) | Hit |
19
+ |---|---|---|---|
20
+ | Q1 pricing summary (full scan, 4 rows) | ~1.4 s | ~1.4 s | **~80 ms** |
21
+ | Revenue by part (50,000 rows) | ~2.1 s | ~2.0 s | **~260 ms** |
22
+
23
+ A miss costs the same as not caching (the persist runs write-behind); a hit
24
+ is 8–18× faster with zero load on your database.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install hotdata-materialized
30
+ ```
31
+
32
+ ```python
33
+ # settings.py
34
+ HOTDATA_MATERIALIZED = {
35
+ "API_KEY": env("HOTDATA_API_KEY"), # hd_... key from app.hotdata.dev
36
+ "WORKSPACE_ID": "work...",
37
+ # optional:
38
+ # "API_URL": "https://api.hotdata.dev",
39
+ # "REGISTRY_DATABASE": "materialized_registry",
40
+ # "INLINE_THRESHOLD_BYTES": 2 * 1024 * 1024,
41
+ # "BACKGROUND": True, # write-behind persists (default)
42
+ }
43
+ ```
44
+
45
+ ## Using it in a Django view
46
+
47
+ Wrap the expensive part in `@materialize`; call it from your view:
48
+
49
+ ```python
50
+ # reports.py
51
+ from django.db.models import Count, Sum
52
+ from hotdata_materialized import materialize
53
+
54
+ from .models import Order
55
+
56
+
57
+ @materialize(ttl=3600)
58
+ def revenue_by_region():
59
+ return (
60
+ Order.objects
61
+ .filter(status="complete")
62
+ .values("region")
63
+ .annotate(orders=Count("id"), revenue=Sum("total"))
64
+ .order_by("-revenue")
65
+ )
66
+ ```
67
+
68
+ ```python
69
+ # views.py
70
+ from django.http import JsonResponse
71
+ from .reports import revenue_by_region
72
+
73
+
74
+ def revenue_view(request):
75
+ frame = revenue_by_region()
76
+ return JsonResponse({"rows": frame.to_pylist(), "cached": frame.cached})
77
+ ```
78
+
79
+ On a **hit** the function never runs and your database is never touched. On a
80
+ **miss** the function runs exactly as it would uncached, the caller gets the
81
+ result immediately, and the persist happens in a background thread. Either
82
+ way you get a `MaterializedFrame`:
83
+
84
+ - `frame.arrow()` — the data as a `pyarrow.Table` (`frame.df()` for pandas,
85
+ `frame.to_pylist()` for dicts, `len(frame)` for the row count)
86
+ - `frame.sql("SELECT region, revenue FROM this ORDER BY revenue DESC LIMIT 5")`
87
+ — SQL runs server-side against the cached entry; `this` names the data.
88
+ Needs a persisted entry: available on hits (on a fresh miss the
89
+ write-behind persist has to land first; `background=False` avoids that)
90
+ - `frame.cached` — which path served it; `frame.entry` — the registry record
91
+
92
+ The wrapped function can return an iterable of dicts (a `.values()` queryset),
93
+ a `pyarrow.Table`, or a pandas DataFrame. Decorator knobs: `ttl=` (seconds,
94
+ `None` = never expires), `version=` (bump to bust the cache on code changes),
95
+ `key=` (human-readable label), `key_fn=` (stable identity for arguments that
96
+ aren't JSON-serializable), `background=False` (block until persisted).
97
+ Fail-open by design: if Hotdata is unreachable, the function runs and its
98
+ result is served uncached — the cache degrades to "no cache," never to
99
+ "no page."
100
+
101
+ ## Search
102
+
103
+ Declare a search index on the decorator and the entry gets it when it
104
+ persists (builds run server-side; a freshly missed entry may error on search
105
+ for a few seconds until its index is ready):
106
+
107
+ ```python
108
+ from hotdata_materialized import BM25, Vector, materialize
109
+
110
+
111
+ @materialize(ttl=3600, index=BM25("notes"))
112
+ def incidents():
113
+ return Incident.objects.values("id", "notes", "severity")
114
+
115
+
116
+ incidents().search("checkout errors outage", column="notes", limit=5)
117
+ ```
118
+
119
+ Vector (semantic) search embeds your text server-side via a workspace
120
+ embedding provider — pass the indexed source column and a natural-language
121
+ query:
122
+
123
+ ```python
124
+ @materialize(ttl=3600, index=Vector("notes", provider="emb_...", metric="cosine"))
125
+ def incidents_semantic():
126
+ return Incident.objects.values("id", "notes")
127
+
128
+
129
+ incidents_semantic().vector_search("the site was down", column="notes", limit=5)
130
+ ```
131
+
132
+ Both return the matching rows as a `pyarrow.Table` with a relevance column
133
+ (`score` / `distance`). One platform rule to know: an embedding-backed vector
134
+ index cannot share an entry with other indexes — the decorator rejects that
135
+ combination up front.
136
+
137
+ ## Evicting entries
138
+
139
+ The decorator composes public primitives (`fingerprint_call` /
140
+ `fingerprint_queryset`, `Registry`, `EntryStore`) that you can use directly —
141
+ for example to drop an entry explicitly:
142
+
143
+ ```python
144
+ from hotdata_materialized import fingerprint_call
145
+ from hotdata_materialized.decorator import get_runtime
146
+
147
+ registry, store = get_runtime()
148
+ store.evict(fingerprint_call(revenue_by_region))
149
+ ```
150
+
151
+ Expired entries stop being served immediately (the `is_expired` check) and
152
+ their databases carry a server-side expiry backstop; a sweep command that
153
+ deletes them proactively is on the roadmap below.
154
+
155
+ ## How it works
156
+
157
+ One managed database (the **registry**) holds one row per entry: fingerprint,
158
+ status, TTL, the entry's database id, and — for small results — the data
159
+ itself as an inline Arrow payload, so a chart-sized hit is served in a single
160
+ API round trip. Larger results live in a per-entry managed database and are
161
+ fetched as an Arrow IPC stream via a result id minted at persist time (no
162
+ re-query on hits). Failures are loud and fail toward "no cache": a failed
163
+ persist leaves no registry row, and the next request simply misses again.
164
+
165
+ See [DESIGN.md](DESIGN.md) for the architecture and the accepted
166
+ trade-offs.
167
+
168
+ ## Roadmap
169
+
170
+ - [x] Content-addressed fingerprinting (querysets and function calls)
171
+ - [x] Remote registry — no migrations or local state in the host app
172
+ - [x] Entry store with write-behind persists
173
+ - [x] Arrow-native reads (inline payloads, persisted-result fetch)
174
+ - [x] `@materialize` decorator and `MaterializedFrame`
175
+ (`.arrow()/.df()/.to_pylist()/.sql()`)
176
+ - [x] TPC-H demo and benchmark
177
+ - [x] CI: pytest, mypy, flake8
178
+ - [ ] Stale-while-revalidate refresh (rebuild protocol)
179
+ - [ ] Sweep command for expired entries
180
+ - [ ] Chainable queryset facade on the frame (`.filter()/.order_by()`)
181
+ - [x] Vector/BM25 index declarations, `frame.search()` and
182
+ `frame.vector_search()`
183
+ - [ ] Async view support (`await frame.aarrow()`)
184
+ - [ ] PyPI release
185
+
186
+ ## Demo
187
+
188
+ [demo/](demo/) is a small Django project that benchmarks TPC-H queries on a
189
+ Neon Postgres directly vs. through the cache:
190
+
191
+ ```bash
192
+ cd demo
193
+ python manage.py compare # Q1: tiny result, inline hit
194
+ python manage.py compare --scenario parts # 50k rows: remote Arrow hit
195
+ ```
196
+
197
+ ## Development
198
+
199
+ ```bash
200
+ uv venv && uv pip install -e '.[test]'
201
+ uv run pytest
202
+ ```
@@ -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)