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.
Files changed (88) hide show
  1. {boti_data-1.0.0/src/boti_data.egg-info → boti_data-1.0.2}/PKG-INFO +425 -19
  2. boti_data-1.0.0/PKG-INFO → boti_data-1.0.2/README.md +412 -35
  3. boti_data-1.0.2/pyproject.toml +93 -0
  4. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/__init__.py +54 -4
  5. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/arrow_schema.py +11 -5
  6. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/connection_catalog.py +50 -11
  7. boti_data-1.0.2/src/boti_data/datacube/__init__.py +28 -0
  8. boti_data-1.0.2/src/boti_data/datacube/artifact.py +225 -0
  9. boti_data-1.0.2/src/boti_data/datacube/base.py +160 -0
  10. boti_data-1.0.2/src/boti_data/datacube/contract.py +97 -0
  11. boti_data-1.0.2/src/boti_data/datacube/resource.py +115 -0
  12. boti_data-1.0.2/src/boti_data/dataset/__init__.py +4 -0
  13. boti_data-1.0.2/src/boti_data/dataset/hybrid.py +408 -0
  14. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/__init__.py +2 -6
  15. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/arrow_schema_mapper.py +131 -103
  16. boti_data-1.0.2/src/boti_data/db/engine_registry.py +142 -0
  17. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_execution.py +94 -12
  18. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_loader.py +60 -21
  19. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_planner.py +270 -66
  20. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/partitioned_types.py +34 -16
  21. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_config.py +28 -22
  22. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_engine.py +39 -18
  23. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_model_builder.py +10 -9
  24. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_model_registry.py +131 -107
  25. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_resource.py +60 -25
  26. boti_data-1.0.2/src/boti_data/enrichment/__init__.py +24 -0
  27. boti_data-1.0.2/src/boti_data/enrichment/async_enricher.py +161 -0
  28. boti_data-1.0.2/src/boti_data/enrichment/composite.py +38 -0
  29. boti_data-1.0.2/src/boti_data/enrichment/protocol.py +31 -0
  30. boti_data-1.0.2/src/boti_data/enrichment/specs.py +45 -0
  31. boti_data-1.0.2/src/boti_data/enrichment/transformers.py +155 -0
  32. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/field_map.py +10 -1
  33. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/__init__.py +2 -0
  34. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/arrow_kernels.py +2 -2
  35. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/expressions.py +6 -6
  36. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/handler.py +73 -71
  37. boti_data-1.0.2/src/boti_data/filters/parquet_compiler.py +81 -0
  38. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/filters/utils.py +127 -94
  39. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/__init__.py +2 -0
  40. boti_data-1.0.2/src/boti_data/gateway/_backend_strategies.py +1342 -0
  41. boti_data-1.0.2/src/boti_data/gateway/_factory.py +75 -0
  42. boti_data-1.0.2/src/boti_data/gateway/_payloads.py +77 -0
  43. boti_data-1.0.2/src/boti_data/gateway/_series_filters.py +87 -0
  44. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/arrow_adapters.py +3 -4
  45. boti_data-1.0.2/src/boti_data/gateway/chunking.py +303 -0
  46. boti_data-1.0.2/src/boti_data/gateway/configured_load.py +208 -0
  47. boti_data-1.0.2/src/boti_data/gateway/core.py +880 -0
  48. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/frame_strategies.py +7 -2
  49. boti_data-1.0.2/src/boti_data/gateway/incremental.py +139 -0
  50. boti_data-1.0.2/src/boti_data/gateway/load_request.py +96 -0
  51. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/loaders.py +55 -61
  52. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/normalization.py +52 -54
  53. boti_data-1.0.2/src/boti_data/gateway/planning.py +319 -0
  54. boti_data-1.0.2/src/boti_data/gateway/policies.py +16 -0
  55. boti_data-1.0.2/src/boti_data/gateway/post_process.py +239 -0
  56. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/gateway/requests.py +84 -25
  57. boti_data-1.0.2/src/boti_data/gateway/return_type.py +215 -0
  58. boti_data-1.0.2/src/boti_data/gateway/sql_guard.py +58 -0
  59. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/helper.py +150 -16
  60. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/joins.py +47 -13
  61. boti_data-1.0.2/src/boti_data/parquet/file_info.py +89 -0
  62. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/parquet/reader.py +32 -84
  63. boti_data-1.0.2/src/boti_data/parquet/reader_config.py +125 -0
  64. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/parquet/resource.py +248 -104
  65. boti_data-1.0.2/src/boti_data/pipelines/__init__.py +21 -0
  66. boti_data-1.0.2/src/boti_data/pipelines/base.py +519 -0
  67. boti_data-1.0.2/src/boti_data/pipelines/registry.py +78 -0
  68. boti_data-1.0.2/src/boti_data/pipelines/sinks.py +609 -0
  69. boti_data-1.0.2/src/boti_data/py.typed +0 -0
  70. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/schema.py +43 -27
  71. boti_data-1.0.2/src/boti_data/watermark/__init__.py +15 -0
  72. boti_data-1.0.2/src/boti_data/watermark/incremental.py +147 -0
  73. boti_data-1.0.2/src/boti_data/watermark/store.py +171 -0
  74. boti_data-1.0.0/README.md → boti_data-1.0.2/src/boti_data.egg-info/PKG-INFO +441 -5
  75. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data.egg-info/SOURCES.txt +38 -2
  76. boti_data-1.0.2/src/boti_data.egg-info/requires.txt +8 -0
  77. boti_data-1.0.0/pyproject.toml +0 -62
  78. boti_data-1.0.0/src/boti_data/db/engine_registry.py +0 -113
  79. boti_data-1.0.0/src/boti_data/distributed.py +0 -182
  80. boti_data-1.0.0/src/boti_data/gateway/core.py +0 -2068
  81. boti_data-1.0.0/src/boti_data.egg-info/requires.txt +0 -12
  82. {boti_data-1.0.0 → boti_data-1.0.2}/setup.cfg +0 -0
  83. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_manager.py +0 -0
  84. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sql_readonly.py +0 -0
  85. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/db/sqlalchemy_async.py +0 -0
  86. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data/parquet/__init__.py +0 -0
  87. {boti_data-1.0.0 → boti_data-1.0.2}/src/boti_data.egg-info/dependency_links.txt +0 -0
  88. {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.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.13
19
+ Requires-Python: >=3.10
17
20
  Description-Content-Type: text/markdown
18
- Requires-Dist: asyncmy>=0.2.11
19
- Requires-Dist: boti<2.0.0,>=0.1.0
20
- Requires-Dist: dask[dataframe,distributed]>=2026.3.0
21
- Requires-Dist: fsspec>=2026.3.0
22
- Requires-Dist: ipykernel>=7.2.0
23
- Requires-Dist: pandas>=3.0.2
24
- Requires-Dist: polars>=1.29.0
25
- Requires-Dist: pyarrow>=23.0.1
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` probes row count and chooses |
475
+ | `auto` | decided at runtime | Unknown result size; `boti-data` uses backend-aware size heuristics |
237
476
 
238
- `return_type="auto"` uses pandas when the result is small (≤ 10,000 rows or ≤ 32 MB) and switches to Dask otherwise. Use it when you do not know the result size in advance and want sensible defaults.
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