boti-data 1.0.0__tar.gz → 1.0.2__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.
- {boti_data-1.0.0/src/boti_data.egg-info → boti_data-1.0.2}/PKG-INFO +425 -19
- boti_data-1.0.0/PKG-INFO → boti_data-1.0.2/README.md +412 -35
- boti_data-1.0.2/pyproject.toml +93 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/__init__.py +54 -4
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/arrow_schema.py +11 -5
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/connection_catalog.py +50 -11
- boti_data-1.0.2/src/boti_data/datacube/__init__.py +28 -0
- boti_data-1.0.2/src/boti_data/datacube/artifact.py +225 -0
- boti_data-1.0.2/src/boti_data/datacube/base.py +160 -0
- boti_data-1.0.2/src/boti_data/datacube/contract.py +97 -0
- boti_data-1.0.2/src/boti_data/datacube/resource.py +115 -0
- boti_data-1.0.2/src/boti_data/dataset/__init__.py +4 -0
- boti_data-1.0.2/src/boti_data/dataset/hybrid.py +408 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/__init__.py +2 -6
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/arrow_schema_mapper.py +131 -103
- boti_data-1.0.2/src/boti_data/db/engine_registry.py +142 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_execution.py +94 -12
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_loader.py +60 -21
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_planner.py +270 -66
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_types.py +34 -16
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_config.py +28 -22
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_engine.py +39 -18
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_model_builder.py +10 -9
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_model_registry.py +131 -107
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_resource.py +60 -25
- boti_data-1.0.2/src/boti_data/enrichment/__init__.py +24 -0
- boti_data-1.0.2/src/boti_data/enrichment/async_enricher.py +161 -0
- boti_data-1.0.2/src/boti_data/enrichment/composite.py +38 -0
- boti_data-1.0.2/src/boti_data/enrichment/protocol.py +31 -0
- boti_data-1.0.2/src/boti_data/enrichment/specs.py +45 -0
- boti_data-1.0.2/src/boti_data/enrichment/transformers.py +155 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/field_map.py +10 -1
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/__init__.py +2 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/arrow_kernels.py +2 -2
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/expressions.py +6 -6
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/handler.py +73 -71
- boti_data-1.0.2/src/boti_data/filters/parquet_compiler.py +81 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/utils.py +127 -94
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/__init__.py +2 -0
- boti_data-1.0.2/src/boti_data/gateway/_backend_strategies.py +1342 -0
- boti_data-1.0.2/src/boti_data/gateway/_factory.py +75 -0
- boti_data-1.0.2/src/boti_data/gateway/_payloads.py +77 -0
- boti_data-1.0.2/src/boti_data/gateway/_series_filters.py +87 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/arrow_adapters.py +3 -4
- boti_data-1.0.2/src/boti_data/gateway/chunking.py +303 -0
- boti_data-1.0.2/src/boti_data/gateway/configured_load.py +208 -0
- boti_data-1.0.2/src/boti_data/gateway/core.py +880 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/frame_strategies.py +7 -2
- boti_data-1.0.2/src/boti_data/gateway/incremental.py +139 -0
- boti_data-1.0.2/src/boti_data/gateway/load_request.py +96 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/loaders.py +55 -61
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/normalization.py +52 -54
- boti_data-1.0.2/src/boti_data/gateway/planning.py +319 -0
- boti_data-1.0.2/src/boti_data/gateway/policies.py +16 -0
- boti_data-1.0.2/src/boti_data/gateway/post_process.py +239 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/requests.py +84 -25
- boti_data-1.0.2/src/boti_data/gateway/return_type.py +215 -0
- boti_data-1.0.2/src/boti_data/gateway/sql_guard.py +58 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/helper.py +150 -16
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/joins.py +47 -13
- boti_data-1.0.2/src/boti_data/parquet/file_info.py +89 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/parquet/reader.py +32 -84
- boti_data-1.0.2/src/boti_data/parquet/reader_config.py +125 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/parquet/resource.py +248 -104
- boti_data-1.0.2/src/boti_data/pipelines/__init__.py +21 -0
- boti_data-1.0.2/src/boti_data/pipelines/base.py +519 -0
- boti_data-1.0.2/src/boti_data/pipelines/registry.py +78 -0
- boti_data-1.0.2/src/boti_data/pipelines/sinks.py +609 -0
- boti_data-1.0.2/src/boti_data/py.typed +0 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/schema.py +43 -27
- boti_data-1.0.2/src/boti_data/watermark/__init__.py +15 -0
- boti_data-1.0.2/src/boti_data/watermark/incremental.py +147 -0
- boti_data-1.0.2/src/boti_data/watermark/store.py +171 -0
- boti_data-1.0.0/README.md → boti_data-1.0.2/src/boti_data.egg-info/PKG-INFO +441 -5
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data.egg-info/SOURCES.txt +38 -2
- boti_data-1.0.2/src/boti_data.egg-info/requires.txt +8 -0
- boti_data-1.0.0/pyproject.toml +0 -62
- boti_data-1.0.0/src/boti_data/db/engine_registry.py +0 -113
- boti_data-1.0.0/src/boti_data/distributed.py +0 -182
- boti_data-1.0.0/src/boti_data/gateway/core.py +0 -2068
- boti_data-1.0.0/src/boti_data.egg-info/requires.txt +0 -12
- {boti_data-1.0.0 → boti_data-1.0.2}/setup.cfg +0 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_manager.py +0 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_readonly.py +0 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sqlalchemy_async.py +0 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/parquet/__init__.py +0 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data.egg-info/dependency_links.txt +0 -0
- {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: boti-data
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Data infrastructure for the Boti ecosystem
|
|
5
5
|
Author-email: Luis Valverde <lvalverdeb@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -11,22 +11,21 @@ Project-URL: Issues, https://github.com/lvalverdeb/boti-data/issues
|
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
18
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
-
Requires-Python: >=3.
|
|
19
|
+
Requires-Python: >=3.10
|
|
17
20
|
Description-Content-Type: text/markdown
|
|
18
|
-
Requires-Dist: asyncmy
|
|
19
|
-
Requires-Dist: boti<2.0.0,>=
|
|
20
|
-
Requires-Dist: dask
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist:
|
|
24
|
-
Requires-Dist:
|
|
25
|
-
Requires-Dist:
|
|
26
|
-
Requires-Dist: pydantic>=2.12.5
|
|
27
|
-
Requires-Dist: pymysql>=1.1.2
|
|
28
|
-
Requires-Dist: s3fs>=2026.3.0
|
|
29
|
-
Requires-Dist: sqlalchemy[asyncio]>=2.0.49
|
|
21
|
+
Requires-Dist: asyncmy<0.3.0,>=0.2.11
|
|
22
|
+
Requires-Dist: boti<2.0.0,>=1.0.2
|
|
23
|
+
Requires-Dist: boti-dask<2.0.0,>=1.0.0
|
|
24
|
+
Requires-Dist: dask[dataframe,distributed]<2027.0.0,>=2026.3.0
|
|
25
|
+
Requires-Dist: pandas<4.0.0,>=3.0.2
|
|
26
|
+
Requires-Dist: polars<2.0.0,>=1.29.0
|
|
27
|
+
Requires-Dist: pymysql<2.0.0,>=1.1.2
|
|
28
|
+
Requires-Dist: sqlalchemy[asyncio]<3.0.0,>=2.0.49
|
|
30
29
|
|
|
31
30
|
# boti-data
|
|
32
31
|
|
|
@@ -123,31 +122,107 @@ pip install "boti[data]"
|
|
|
123
122
|
|
|
124
123
|
## Imports
|
|
125
124
|
|
|
126
|
-
`boti-data` uses the top-level Python package `boti_data
|
|
125
|
+
`boti-data` uses the top-level Python package `boti_data`. Dask runtime/session/resilience helpers live in `boti_dask` and are not re-exported here.
|
|
127
126
|
|
|
128
127
|
```python
|
|
129
128
|
from boti_data import (
|
|
129
|
+
AsyncFrameEnricher,
|
|
130
|
+
AttachmentSpec,
|
|
131
|
+
CsvSink,
|
|
132
|
+
CsvSinkConfig,
|
|
130
133
|
ConnectionCatalog,
|
|
134
|
+
DatacubeConfig,
|
|
135
|
+
DatacubeContract,
|
|
131
136
|
DataGateway,
|
|
132
137
|
DataHelper,
|
|
133
|
-
FieldMap,
|
|
138
|
+
FieldMap,
|
|
139
|
+
HybridDataset,
|
|
140
|
+
JsonlSink,
|
|
141
|
+
JsonlSinkConfig,
|
|
134
142
|
ParquetDataConfig,
|
|
135
143
|
ParquetDataResource,
|
|
144
|
+
ParquetPipeline,
|
|
145
|
+
SinkRegistry,
|
|
146
|
+
SinkPipeline,
|
|
147
|
+
available_sinks,
|
|
148
|
+
create_sink,
|
|
149
|
+
register_sink,
|
|
136
150
|
SqlAlchemyModelBuilder,
|
|
137
151
|
SqlDatabaseConfig,
|
|
138
152
|
SqlDatabaseResource,
|
|
139
153
|
)
|
|
140
154
|
```
|
|
141
155
|
|
|
156
|
+
For Dask runtime/session/resilience utilities, import from `boti_dask` directly:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from boti_dask import (
|
|
160
|
+
DaskSession,
|
|
161
|
+
UniqueValuesExtractor,
|
|
162
|
+
apply_recommended_dask_config,
|
|
163
|
+
async_safe_compute,
|
|
164
|
+
async_safe_gather,
|
|
165
|
+
async_safe_head,
|
|
166
|
+
async_safe_persist,
|
|
167
|
+
async_safe_wait,
|
|
168
|
+
dask_is_empty,
|
|
169
|
+
dask_is_probably_empty,
|
|
170
|
+
dask_session,
|
|
171
|
+
inspect_graph,
|
|
172
|
+
safe_compute,
|
|
173
|
+
safe_gather,
|
|
174
|
+
safe_head,
|
|
175
|
+
safe_persist,
|
|
176
|
+
safe_wait,
|
|
177
|
+
)
|
|
178
|
+
```
|
|
179
|
+
|
|
142
180
|
Lower-level modules are also available:
|
|
143
181
|
|
|
144
182
|
```python
|
|
145
183
|
from boti_data.db import SqlDatabaseConfig, SqlDatabaseResource
|
|
184
|
+
from boti_data.datacube import DatacubeConfig, DatacubeContract
|
|
146
185
|
from boti_data.gateway import DataGateway
|
|
147
186
|
from boti_data.parquet import ParquetDataConfig, ParquetDataResource
|
|
148
187
|
from boti_data.schema import validate_schema
|
|
149
188
|
```
|
|
150
189
|
|
|
190
|
+
## Datacube backend
|
|
191
|
+
|
|
192
|
+
`DataGateway` also supports a callable-backed `datacube` backend for in-process cube loaders while keeping the same return-type API (`pandas`/`dask`/`arrow`/`polars`).
|
|
193
|
+
|
|
194
|
+
```python
|
|
195
|
+
import pandas as pd
|
|
196
|
+
|
|
197
|
+
from boti_data import DatacubeConfig, DatacubeContract, DataGateway
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def loader(request):
|
|
201
|
+
frame = pd.DataFrame({"id": [1, 2], "status": ["active", "inactive"]})
|
|
202
|
+
if request.filters.get("status__exact") == "active":
|
|
203
|
+
frame = frame[frame["status"] == "active"]
|
|
204
|
+
return frame
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
contract = DatacubeContract(
|
|
208
|
+
request_transformer=lambda request: request.model_copy(update={"cube": request.cube or "orders_v2"}),
|
|
209
|
+
frame_transformer=lambda frame, request: frame.assign(cube=request.cube),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
with DataGateway(
|
|
213
|
+
DatacubeConfig(loader=loader, default_cube="orders", contract=contract),
|
|
214
|
+
table="orders",
|
|
215
|
+
sticky_filters={"status__exact": "active"},
|
|
216
|
+
) as gateway:
|
|
217
|
+
df = gateway.load(return_type="pandas")
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
You can also configure this path with `DataGateway.from_config({"backend": "datacube", ...})` or `DataHelper({"backend": "datacube", ...})`.
|
|
221
|
+
|
|
222
|
+
See `docs/DATACUBE_CONTRACT.md` for hook ordering, loader precedence, and validator rejection guidance.
|
|
223
|
+
See `docs/SECURITY_HARDENING.md` for SQL/raw-sql policy and distributed credential hardening defaults.
|
|
224
|
+
For a runnable rejection flow, see `examples/data_facade_datacube_contract_rejection.py`.
|
|
225
|
+
|
|
151
226
|
## DataHelper
|
|
152
227
|
|
|
153
228
|
`DataHelper` is the primary entry point for most use cases. It is a thin facade over `DataGateway` that provides a clean, consistent interface for loading data whether you are working locally, in a notebook, or inside a distributed Dask pipeline.
|
|
@@ -196,6 +271,170 @@ async with DataHelper(config, table="orders") as helper:
|
|
|
196
271
|
df = await helper.aload(status="confirmed")
|
|
197
272
|
```
|
|
198
273
|
|
|
274
|
+
For synchronous scripts that still need to call async load paths, use the explicit sync bridge:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
with DataHelper(config, table="orders") as helper:
|
|
278
|
+
df = helper.aload_sync(status="confirmed")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
If an event loop is already running (for example notebooks or ASGI handlers), use `await helper.aload(...)` directly.
|
|
282
|
+
|
|
283
|
+
## ParquetPipeline
|
|
284
|
+
|
|
285
|
+
`ParquetPipeline` is the first orchestration layer built on top of `DataHelper` and `HybridDataset`.
|
|
286
|
+
It materializes a lazy source load into a parquet dataset directory and can optionally reload the
|
|
287
|
+
written dataset through `ParquetReader`.
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from boti_data import DataHelper, ParquetPipeline
|
|
291
|
+
|
|
292
|
+
helper = DataHelper(
|
|
293
|
+
backend="sqlalchemy",
|
|
294
|
+
connection_url=f"sqlite:///{db_path}",
|
|
295
|
+
poolclass="sqlalchemy.pool.NullPool",
|
|
296
|
+
query_only=False,
|
|
297
|
+
table="source_events",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
pipeline = ParquetPipeline(
|
|
301
|
+
helper,
|
|
302
|
+
{
|
|
303
|
+
"backend": "parquet",
|
|
304
|
+
"storage_path": "/tmp/source_events_dataset",
|
|
305
|
+
"partition_on": ["partition_date"],
|
|
306
|
+
},
|
|
307
|
+
date_field="event_date",
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
result = pipeline.materialize(
|
|
311
|
+
filters={"status__exact": "active"},
|
|
312
|
+
reload=True,
|
|
313
|
+
reload_options={"filters": {"partition_date__exact": "2026-04-17"}},
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
assert result.reloaded is True
|
|
317
|
+
frame = result.frame
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Use `materialize()` / `amaterialize()` for the one-step workflow, or call `to_parquet()` and
|
|
321
|
+
`from_parquet()` separately when you want explicit control over the write and reload phases.
|
|
322
|
+
See `examples/data_parquet_pipeline.py` for a runnable end-to-end example.
|
|
323
|
+
|
|
324
|
+
## SinkPipeline and Sinks
|
|
325
|
+
|
|
326
|
+
Phase 3 adds a minimal sink/plugin layer next to `ParquetPipeline`.
|
|
327
|
+
|
|
328
|
+
- `SinkPipeline` orchestrates `DataHelper` / `HybridDataset` loads into any sink implementing the pipeline sink contract
|
|
329
|
+
- `CsvSink` and `JsonlSink` are write-only dataset sinks (`.csv` / `.jsonl` shards)
|
|
330
|
+
- `ParquetPipeline` now uses the same sink abstraction internally via `ParquetSink`
|
|
331
|
+
|
|
332
|
+
Use the sink registry for named sink creation:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
from boti_data import SinkPipeline, available_sinks
|
|
336
|
+
|
|
337
|
+
assert "jsonl" in available_sinks()
|
|
338
|
+
|
|
339
|
+
pipeline = SinkPipeline(
|
|
340
|
+
helper,
|
|
341
|
+
"jsonl",
|
|
342
|
+
sink_config={
|
|
343
|
+
"storage_path": "/tmp/source_events_jsonl",
|
|
344
|
+
"partition_on": ["partition_date"],
|
|
345
|
+
},
|
|
346
|
+
date_field="event_date",
|
|
347
|
+
)
|
|
348
|
+
result = pipeline.write(filters={"status__exact": "active"})
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
from boti_data import CsvSink, DataHelper, SinkPipeline
|
|
353
|
+
|
|
354
|
+
helper = DataHelper(
|
|
355
|
+
backend="sqlalchemy",
|
|
356
|
+
connection_url=f"sqlite:///{db_path}",
|
|
357
|
+
poolclass="sqlalchemy.pool.NullPool",
|
|
358
|
+
query_only=False,
|
|
359
|
+
table="source_events",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
sink = CsvSink(
|
|
363
|
+
{
|
|
364
|
+
"storage_path": "/tmp/source_events_csv",
|
|
365
|
+
"partition_on": ["partition_date"],
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
pipeline = SinkPipeline(helper, sink, date_field="event_date")
|
|
370
|
+
result = pipeline.write(filters={"status__exact": "active"})
|
|
371
|
+
|
|
372
|
+
assert result.files
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`CsvSink`/`JsonlSink` are intentionally write-only in this release. Use `ParquetPipeline` when you need
|
|
376
|
+
write + reload orchestration. See `examples/data_csv_sink_pipeline.py` and
|
|
377
|
+
`examples/data_jsonl_sink_pipeline.py` for runnable examples.
|
|
378
|
+
|
|
379
|
+
## Enrichment v1
|
|
380
|
+
|
|
381
|
+
`AsyncFrameEnricher` adds declarative attachment-based enrichment before downstream writes.
|
|
382
|
+
Select attachments by `AttachmentSpec.key` via `cols=[...]`, and enforce bounded unique-value
|
|
383
|
+
extraction with `max_unique_values`.
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
from boti_data import AsyncFrameEnricher, AttachmentSpec
|
|
387
|
+
|
|
388
|
+
enricher = AsyncFrameEnricher(
|
|
389
|
+
[
|
|
390
|
+
AttachmentSpec(
|
|
391
|
+
key="customer_segment",
|
|
392
|
+
required_cols={"customer_id"},
|
|
393
|
+
attachment_fn=customer_segment_attachment,
|
|
394
|
+
col_to_kwarg={"customer_id": "ids"},
|
|
395
|
+
left_on=["customer_id"],
|
|
396
|
+
right_on=["id"],
|
|
397
|
+
drop_cols=["id"],
|
|
398
|
+
max_unique_values=5000,
|
|
399
|
+
)
|
|
400
|
+
]
|
|
401
|
+
)
|
|
402
|
+
enriched = await enricher.aenrich(base_frame, cols=["customer_segment"])
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`SinkPipeline` accepts `enricher=...` and optional `enrich_cols=[...]` to apply enrichment right
|
|
406
|
+
before sink writes.
|
|
407
|
+
|
|
408
|
+
### Raw `sql=` safety policy (eager SQL only)
|
|
409
|
+
|
|
410
|
+
`DataGateway` supports convenience `sql="..."` reads for eager SQL paths, with explicit safety controls:
|
|
411
|
+
|
|
412
|
+
- raw SQL is validated as read-only single-statement `SELECT`/`WITH`
|
|
413
|
+
- mutating SQL (`INSERT`, `UPDATE`, `DELETE`, `DROP`, etc.) is rejected
|
|
414
|
+
- multi-statement SQL is rejected
|
|
415
|
+
- lazy SQL still requires `statement` + `model` (raw `sql=` is eager-only)
|
|
416
|
+
|
|
417
|
+
Use constructor policy `raw_sql_policy` to control availability:
|
|
418
|
+
|
|
419
|
+
- `raw_sql_policy="readonly_opt_in"` (default): call site must pass `allow_raw_sql=True`
|
|
420
|
+
- `raw_sql_policy="disabled"`: raw `sql=` is blocked even if `allow_raw_sql=True`
|
|
421
|
+
|
|
422
|
+
```python
|
|
423
|
+
from boti_data import DataGateway, SqlDatabaseConfig
|
|
424
|
+
|
|
425
|
+
config = SqlDatabaseConfig(connection_url="sqlite:///example.db", query_only=True)
|
|
426
|
+
|
|
427
|
+
with DataGateway(config, raw_sql_policy="readonly_opt_in") as gateway:
|
|
428
|
+
df = gateway.load(
|
|
429
|
+
sql="SELECT id, status FROM users WHERE status = :status",
|
|
430
|
+
params={"status": "active"},
|
|
431
|
+
as_pandas=True,
|
|
432
|
+
allow_raw_sql=True,
|
|
433
|
+
)
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
Prefer `statement` + `model` for production query paths whenever possible, especially when you need lazy/partitioned execution.
|
|
437
|
+
|
|
199
438
|
---
|
|
200
439
|
|
|
201
440
|
## Output engines: pandas, polars, and dask
|
|
@@ -233,9 +472,14 @@ df = helper.load(status="confirmed", return_type="arrow") # pyarrow.Table
|
|
|
233
472
|
| `polars` | `polars.DataFrame` | CPU-intensive transforms, single-machine performance |
|
|
234
473
|
| `arrow` | `pyarrow.Table` | Zero-copy interchange, serialisation, ML pipelines |
|
|
235
474
|
| `dask` | `dask.dataframe.DataFrame` | Large datasets, distributed clusters, lazy evaluation |
|
|
236
|
-
| `auto` | decided at runtime | Unknown result size; `boti-data`
|
|
475
|
+
| `auto` | decided at runtime | Unknown result size; `boti-data` uses backend-aware size heuristics |
|
|
237
476
|
|
|
238
|
-
`return_type="auto"` uses pandas
|
|
477
|
+
`return_type="auto"` uses pandas for small results and switches to Dask for larger scans, with backend-specific checks:
|
|
478
|
+
|
|
479
|
+
- SQL: probes up to 10,000 rows (and respects `limit` / explicit `partitioned` controls).
|
|
480
|
+
- Parquet: uses pandas when the selected scan is small enough to estimate eagerly (typically up to 4 files and <= 32 MB total), otherwise Dask.
|
|
481
|
+
|
|
482
|
+
Use `auto` when result size is uncertain and you want sensible defaults without hardcoding an engine.
|
|
239
483
|
|
|
240
484
|
---
|
|
241
485
|
|
|
@@ -270,6 +514,98 @@ async def get_orders(status: str) -> pd.DataFrame:
|
|
|
270
514
|
return await helper.pandas.aload(status=status)
|
|
271
515
|
```
|
|
272
516
|
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## Dask resilience helpers
|
|
520
|
+
|
|
521
|
+
`boti-data` integrates with `boti_dask` for opt-in Dask runtime and resilience workflows.
|
|
522
|
+
|
|
523
|
+
Available helpers:
|
|
524
|
+
|
|
525
|
+
- `inspect_graph(...)`
|
|
526
|
+
- `safe_compute(...)`
|
|
527
|
+
- `safe_head(...)`
|
|
528
|
+
- `safe_gather(...)`
|
|
529
|
+
- `safe_persist(...)`
|
|
530
|
+
- `safe_wait(...)`
|
|
531
|
+
- `async_safe_compute(...)`
|
|
532
|
+
- `async_safe_head(...)`
|
|
533
|
+
- `async_safe_gather(...)`
|
|
534
|
+
- `async_safe_persist(...)`
|
|
535
|
+
- `async_safe_wait(...)`
|
|
536
|
+
- `dask_is_probably_empty(...)`
|
|
537
|
+
- `dask_is_empty(...)`
|
|
538
|
+
- `UniqueValuesExtractor`
|
|
539
|
+
- `apply_recommended_dask_config(...)`
|
|
540
|
+
|
|
541
|
+
### Core rules
|
|
542
|
+
|
|
543
|
+
- Dask DataFrame-like graphs do **not** silently fall back to local threaded compute.
|
|
544
|
+
- Retry behavior is limited to recoverable communication failures.
|
|
545
|
+
- The helpers are opt-in and designed to complement explicit `DaskSession` ownership.
|
|
546
|
+
|
|
547
|
+
### Minimal example
|
|
548
|
+
|
|
549
|
+
```python
|
|
550
|
+
import asyncio
|
|
551
|
+
import dask
|
|
552
|
+
import dask.dataframe as dd
|
|
553
|
+
import pandas as pd
|
|
554
|
+
|
|
555
|
+
from boti_dask import (
|
|
556
|
+
apply_recommended_dask_config,
|
|
557
|
+
async_safe_compute,
|
|
558
|
+
dask_session,
|
|
559
|
+
inspect_graph,
|
|
560
|
+
safe_compute,
|
|
561
|
+
safe_persist,
|
|
562
|
+
safe_wait,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
frame = dd.from_pandas(pd.DataFrame({"id": [1, 2, 3]}), npartitions=2)
|
|
566
|
+
|
|
567
|
+
with apply_recommended_dask_config():
|
|
568
|
+
with dask_session(cluster_kwargs={"n_workers": 1, "threads_per_worker": 1, "processes": False}) as client:
|
|
569
|
+
metrics = inspect_graph(frame)
|
|
570
|
+
persisted = safe_persist(frame, dask_client=client)
|
|
571
|
+
safe_wait(persisted, dask_client=client)
|
|
572
|
+
total = safe_compute(frame["id"].sum(), dask_client=client)
|
|
573
|
+
preview = safe_head(frame, n=2, dask_client=client)
|
|
574
|
+
|
|
575
|
+
async def run_async() -> int:
|
|
576
|
+
delayed_value = dask.delayed(lambda: 6 * 7)()
|
|
577
|
+
return await async_safe_compute(delayed_value, dask_client=client)
|
|
578
|
+
|
|
579
|
+
async_total = asyncio.run(run_async())
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
### Dry-run and preview on gateway loads
|
|
583
|
+
|
|
584
|
+
Use `dry_run=True` to build and inspect a lazy Dask load graph without materializing it, and `preview(...)` / `apreview(...)` to safely sample rows inside the session:
|
|
585
|
+
|
|
586
|
+
```python
|
|
587
|
+
with DataHelper.session(scheduler_address="tcp://scheduler:8786", verify_connectivity=True) as client:
|
|
588
|
+
with DataHelper(config, table="transactions") as helper:
|
|
589
|
+
ddf = helper.dask.load(year=2024, dry_run=True, diagnostics=True)
|
|
590
|
+
preview = helper.dask.preview(year=2024, n=5)
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Resilient joins
|
|
594
|
+
|
|
595
|
+
For large indexed distributed joins, opt into resilient persistence:
|
|
596
|
+
|
|
597
|
+
```python
|
|
598
|
+
joined = helper.left_join(
|
|
599
|
+
left,
|
|
600
|
+
right,
|
|
601
|
+
join_key="id",
|
|
602
|
+
join_schema_map={"id": "Int64"},
|
|
603
|
+
persist=True,
|
|
604
|
+
resilient=True,
|
|
605
|
+
diagnostics=True,
|
|
606
|
+
)
|
|
607
|
+
```
|
|
608
|
+
|
|
273
609
|
### Parquet sources
|
|
274
610
|
|
|
275
611
|
```python
|
|
@@ -335,8 +671,57 @@ with DataHelper.session(scheduler_address="tcp://scheduler:8786") as client:
|
|
|
335
671
|
# Subsequent operations reuse the persisted graph
|
|
336
672
|
monthly = ddf.groupby("month").agg({"amount": "sum"}).compute()
|
|
337
673
|
by_region = ddf.groupby("region").size().compute()
|
|
674
|
+
|
|
675
|
+
# Preview persisted data before the managed session closes.
|
|
676
|
+
preview = helper.preview(year=2024, n=5, persist=True)
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
### Shared sessions for repeated notebook cells
|
|
680
|
+
|
|
681
|
+
When repeated cells need to reuse the same scheduler connection, opt into shared-session reuse explicitly:
|
|
682
|
+
|
|
683
|
+
```python
|
|
684
|
+
with DataHelper.session(
|
|
685
|
+
scheduler_address="tcp://scheduler:8786",
|
|
686
|
+
shared=True,
|
|
687
|
+
shared_key="notebook-main-cluster",
|
|
688
|
+
) as client:
|
|
689
|
+
with DataHelper(config, table="transactions") as helper:
|
|
690
|
+
ddf = helper.dask.load(year=2024)
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
### HybridDataset distributed pattern
|
|
694
|
+
|
|
695
|
+
Use this pattern when you split reads across historical and live sources but want one
|
|
696
|
+
distributed execution context. `HybridDataset` composes two helpers, and
|
|
697
|
+
`DataHelper.session(...)` keeps scheduler ownership explicit.
|
|
698
|
+
|
|
699
|
+
```python
|
|
700
|
+
import asyncio
|
|
701
|
+
|
|
702
|
+
from dask.distributed import LocalCluster
|
|
703
|
+
from boti_data import DataHelper, HybridDataset
|
|
704
|
+
|
|
705
|
+
historical = DataHelper(backend="sqlalchemy", connection_url="sqlite:///events.db", table="events_hist")
|
|
706
|
+
live = DataHelper(backend="sqlalchemy", connection_url="sqlite:///events.db", table="events_live")
|
|
707
|
+
dataset = HybridDataset(historical, live, date_field="event_date", split_date="2026-04-18")
|
|
708
|
+
|
|
709
|
+
with LocalCluster(n_workers=1, threads_per_worker=1, processes=False, dashboard_address=":0") as cluster:
|
|
710
|
+
with DataHelper.session(
|
|
711
|
+
scheduler_address=cluster.scheduler_address,
|
|
712
|
+
verify_connectivity=True,
|
|
713
|
+
shared=True,
|
|
714
|
+
shared_key="hybrid-dataset-distributed",
|
|
715
|
+
):
|
|
716
|
+
ddf = dataset.dask.load(start="2026-04-14", end="2026-04-19", diagnostics=True)
|
|
717
|
+
frame = ddf.compute()
|
|
718
|
+
eager_frame = asyncio.run(
|
|
719
|
+
dataset.aload(start="2026-04-16", end="2026-04-18", return_type="pandas")
|
|
720
|
+
)
|
|
338
721
|
```
|
|
339
722
|
|
|
723
|
+
For a complete runnable script, see `examples/data_hybrid_dataset_distributed.py`.
|
|
724
|
+
|
|
340
725
|
### Semi-join across distributed frames
|
|
341
726
|
|
|
342
727
|
```python
|
|
@@ -428,7 +813,7 @@ Setting `worker_connection_env_var` prevents both problems and is the recommende
|
|
|
428
813
|
|
|
429
814
|
### Parquet in distributed settings
|
|
430
815
|
|
|
431
|
-
Parquet resources use `fsspec` for filesystem access. The filesystem object is not pickled directly; instead, `ParquetDataResource` uses a `fs_factory` callable or a `filesystem_profile` name that workers can use to reconstruct the filesystem independently.
|
|
816
|
+
Parquet resources use `fsspec` for filesystem access. The filesystem object is not pickled directly; instead, `ParquetDataResource` uses a `fs_factory` callable or a `filesystem_profile` name that workers can use to reconstruct the filesystem independently. When `filesystem_profile` is used, filesystem creation is routed through `ConnectionCatalog` adapters so retry/cache behavior stays consistent.
|
|
432
817
|
|
|
433
818
|
```python
|
|
434
819
|
from boti_data import DataHelper, ParquetDataConfig, ConnectionCatalog
|
|
@@ -461,6 +846,7 @@ Use the following as a guide:
|
|
|
461
846
|
| Data does not fit in one machine's RAM | `helper.dask.load()` + local or remote cluster |
|
|
462
847
|
| Heavy transforms over millions of rows | `helper.dask.load()` + Dask cluster |
|
|
463
848
|
| Async service (FastAPI, ASGI) | `await helper.pandas.aload()` or `await helper.dask.aload()` |
|
|
849
|
+
| Sync script that needs async load path | `helper.aload_sync(...)` |
|
|
464
850
|
| Joining two large tables on a cluster | `helper.semi_join(series, on="key")` |
|
|
465
851
|
| Scheduled overnight batch job | Dask cluster + `persist=True` for multi-pass jobs |
|
|
466
852
|
|
|
@@ -566,6 +952,26 @@ with DataHelper(config, table="transactions") as helper:
|
|
|
566
952
|
df = helper.pandas.load_period("created_at", "2024-01-01", "2024-06-30")
|
|
567
953
|
```
|
|
568
954
|
|
|
955
|
+
### HybridDataset — historical + live composition
|
|
956
|
+
|
|
957
|
+
```python
|
|
958
|
+
historical = DataHelper(backend="parquet", storage_path="/data/historical", parquet_filename="orders")
|
|
959
|
+
live = DataHelper(backend="sqlalchemy", connection_url="sqlite:///live.db", table="orders_live")
|
|
960
|
+
|
|
961
|
+
dataset = HybridDataset(
|
|
962
|
+
historical,
|
|
963
|
+
live,
|
|
964
|
+
date_field="order_date",
|
|
965
|
+
split_date="2026-04-18",
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
# mixed window (auto -> dask for hybrid composition)
|
|
969
|
+
ddf = dataset.load(start="2026-04-15", end="2026-04-20", return_type="auto")
|
|
970
|
+
|
|
971
|
+
# explicit source override through engine-bound view
|
|
972
|
+
pdf = dataset.pandas.load(start="2026-04-18", end="2026-04-20", source="live")
|
|
973
|
+
```
|
|
974
|
+
|
|
569
975
|
### DataHelper — parquet source
|
|
570
976
|
|
|
571
977
|
```python
|