boti-data 1.3.1__tar.gz → 1.3.3__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.3.1 → boti_data-1.3.3}/PKG-INFO +1 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/pyproject.toml +1 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/__init__.py +12 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/__init__.py +6 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/arrow_schema_mapper.py +31 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_config.py +19 -7
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_registry.py +63 -48
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/__init__.py +8 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_backend_strategies.py +28 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/core.py +27 -1
- boti_data-1.3.3/src/boti_data/gateway/core_describe.py +45 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/loaders.py +8 -2
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/requests.py +16 -0
- boti_data-1.3.3/src/boti_data/gateway/sql_describe.py +41 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_strategy.py +31 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/helper.py +15 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/__init__.py +6 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/__init__.py +12 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks.py +3 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_common.py +82 -17
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_parquet.py +39 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/PKG-INFO +1 -1
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/SOURCES.txt +2 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/README.md +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/setup.cfg +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/arrow_schema.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/connection_catalog.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/__init__.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/artifact.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/base.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/contract.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/resource.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/dataset/__init__.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/dataset/hybrid.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/dataset/hybrid_support.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/engine_registry.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_execution.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_loader.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_planner.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_planner_async.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_planning_math.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_row_materialization.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_statements.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_types.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_engine.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_manager.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_builder.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_module_injection.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_registry_types.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_readonly.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_repository.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_resource.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sqlalchemy_async.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/vector_search.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/__init__.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/async_enricher.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/composite.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/protocol.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/specs.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/transformers.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/field_map.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/__init__.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_string.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/backend_ops.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/expressions.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/handler.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/parquet_compiler.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/utils.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/value_parsing.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_factory.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_gateway_init.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_payloads.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_series_filters.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/arrow_adapters.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/build_context.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/chunking.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/configured_load.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/core_load.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/datacube_strategy.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/frame_convert.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/frame_strategies.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/incremental.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/load_execution.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/load_request.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/normalization.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/parquet_strategy.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/planning.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/policies.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/post_process.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/return_type.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/select_cache.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/semi_join.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_guard.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_size_estimation.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/joins.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/config.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/discovery.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/file_info.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/filesystem.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/reader.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/reader_config.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/resource.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/schema_filters.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/base.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/registry.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_text.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/py.typed +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/schema.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/watermark/__init__.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/watermark/incremental.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/watermark/store.py +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/dependency_links.txt +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/requires.txt +0 -0
- {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/top_level.txt +0 -0
|
@@ -63,6 +63,7 @@ __all__ = [
|
|
|
63
63
|
"SqlModelRegistry",
|
|
64
64
|
"SqlRepository",
|
|
65
65
|
"SqlUnitOfWork",
|
|
66
|
+
"TableDescription",
|
|
66
67
|
"TrueExpr",
|
|
67
68
|
"VectorMetric",
|
|
68
69
|
"WatermarkStore",
|
|
@@ -70,6 +71,7 @@ __all__ = [
|
|
|
70
71
|
"align_frames_for_join",
|
|
71
72
|
"apply_schema_map",
|
|
72
73
|
"available_sinks",
|
|
74
|
+
"awrite_parquet",
|
|
73
75
|
"build_incremental_filters",
|
|
74
76
|
"create_sink",
|
|
75
77
|
"ensure_greenlet_available",
|
|
@@ -83,6 +85,7 @@ __all__ = [
|
|
|
83
85
|
"register_sink",
|
|
84
86
|
"validate_schema",
|
|
85
87
|
"vector_distance",
|
|
88
|
+
"write_parquet",
|
|
86
89
|
]
|
|
87
90
|
|
|
88
91
|
# Every public name is loaded lazily (PEP 562) so that importing boti_data (or
|
|
@@ -132,6 +135,7 @@ _LAZY = {
|
|
|
132
135
|
"SqlLoadRequest": ("boti_data.gateway", "SqlLoadRequest"),
|
|
133
136
|
"DataFrameOptions": ("boti_data.gateway", "DataFrameOptions"),
|
|
134
137
|
"DataFrameParams": ("boti_data.gateway", "DataFrameParams"),
|
|
138
|
+
"TableDescription": ("boti_data.gateway", "TableDescription"),
|
|
135
139
|
"DataHelper": ("boti_data.helper", "DataHelper"),
|
|
136
140
|
"indexed_left_join": ("boti_data.joins", "indexed_left_join"),
|
|
137
141
|
"left_join_frames": ("boti_data.joins", "left_join_frames"),
|
|
@@ -161,8 +165,10 @@ _LAZY = {
|
|
|
161
165
|
"SinkRegistry": ("boti_data.pipelines", "SinkRegistry"),
|
|
162
166
|
"SinkWriteResult": ("boti_data.pipelines", "SinkWriteResult"),
|
|
163
167
|
"available_sinks": ("boti_data.pipelines", "available_sinks"),
|
|
168
|
+
"awrite_parquet": ("boti_data.pipelines", "awrite_parquet"),
|
|
164
169
|
"create_sink": ("boti_data.pipelines", "create_sink"),
|
|
165
170
|
"register_sink": ("boti_data.pipelines", "register_sink"),
|
|
171
|
+
"write_parquet": ("boti_data.pipelines", "write_parquet"),
|
|
166
172
|
"FileWatermarkStore": ("boti_data.watermark", "FileWatermarkStore"),
|
|
167
173
|
"FsspecWatermarkStore": ("boti_data.watermark", "FsspecWatermarkStore"),
|
|
168
174
|
"IncrementalResult": ("boti_data.watermark", "IncrementalResult"),
|
|
@@ -172,6 +178,12 @@ _LAZY = {
|
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
|
|
181
|
+
# Deliberately identical to the __getattr__ in boti_data/db/__init__.py and
|
|
182
|
+
# boti_data/parquet/__init__.py -- this is PEP 562's own documented
|
|
183
|
+
# module-level lazy-import boilerplate. Each copy needs its own private
|
|
184
|
+
# _LAZY dict/globals()/__name__, so sharing an implementation would need an
|
|
185
|
+
# awkward globals()-passing helper for no real gain over these 6 lines.
|
|
186
|
+
# spaghetti-ignore[duplicate-function-body]: see above
|
|
175
187
|
def __getattr__(name: str) -> Any:
|
|
176
188
|
if name in _LAZY:
|
|
177
189
|
module_name, attr = _LAZY[name]
|
|
@@ -63,6 +63,12 @@ _LAZY = {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
# Deliberately identical to the __getattr__ in boti_data/__init__.py and
|
|
67
|
+
# boti_data/parquet/__init__.py -- this is PEP 562's own documented
|
|
68
|
+
# module-level lazy-import boilerplate. Each copy needs its own private
|
|
69
|
+
# _LAZY dict/globals()/__name__, so sharing an implementation would need an
|
|
70
|
+
# awkward globals()-passing helper for no real gain over these 6 lines.
|
|
71
|
+
# spaghetti-ignore[duplicate-function-body]: see above
|
|
66
72
|
def __getattr__(name: str) -> Any:
|
|
67
73
|
if name in _LAZY:
|
|
68
74
|
module_name, attr = _LAZY[name]
|
|
@@ -274,6 +274,37 @@ def build_empty_arrow_table(schema: pa.Schema) -> pa.Table:
|
|
|
274
274
|
return pa.Table.from_arrays(arrays, schema=schema)
|
|
275
275
|
|
|
276
276
|
|
|
277
|
+
def arrow_table_from_pylist_with_fallback(
|
|
278
|
+
rows: list[dict[str, Any]],
|
|
279
|
+
columns: list[str],
|
|
280
|
+
) -> pa.Table:
|
|
281
|
+
"""Build a Table from raw dict rows with no SQLAlchemy schema to guide it.
|
|
282
|
+
|
|
283
|
+
Used for raw-SQL-text loads, where there is no ``Select`` statement to
|
|
284
|
+
derive column types from, so pyarrow must infer everything from the
|
|
285
|
+
Python values themselves. A column of otherwise-uniform values pyarrow's
|
|
286
|
+
inference can't place (``uuid.UUID``, ``decimal.Decimal``, ...) fails the
|
|
287
|
+
*entire* table with an opaque ``ArrowInvalid`` naming the value, never the
|
|
288
|
+
column. Isolate any such column and fall back to stringifying just that
|
|
289
|
+
column instead of failing the whole load.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
return pa.Table.from_pylist(rows)
|
|
293
|
+
except (pa.ArrowInvalid, pa.ArrowTypeError):
|
|
294
|
+
_logger.debug("Whole-table inference failed, falling back to per-column coercion")
|
|
295
|
+
|
|
296
|
+
arrays: dict[str, pa.Array] = {}
|
|
297
|
+
for column in columns:
|
|
298
|
+
values = [row.get(column) for row in rows]
|
|
299
|
+
try:
|
|
300
|
+
arrays[column] = pa.array(values)
|
|
301
|
+
except (pa.ArrowInvalid, pa.ArrowTypeError):
|
|
302
|
+
arrays[column] = pa.array(
|
|
303
|
+
[str(value) if value is not None else None for value in values]
|
|
304
|
+
)
|
|
305
|
+
return pa.Table.from_pydict(arrays)
|
|
306
|
+
|
|
307
|
+
|
|
277
308
|
# ---------------------------------------------------------------------------
|
|
278
309
|
# Arrow → pandas conversion with type preservation
|
|
279
310
|
# ---------------------------------------------------------------------------
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import warnings
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
from typing import Any, Union
|
|
6
5
|
|
|
6
|
+
from boti.core.logger import Logger
|
|
7
7
|
from boti.core.models import ResourceConfig
|
|
8
8
|
from boti.core.security import is_valid_env_var_name
|
|
9
9
|
from boti.core.settings import SqlDatabaseSettings, load_prefixed_model
|
|
@@ -18,7 +18,19 @@ _ALLOWED_POOLCLASS_IMPORTS = {
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class SqlDatabaseConfig(ResourceConfig):
|
|
21
|
-
"""Immutable configuration for SQLAlchemy engine initialization.
|
|
21
|
+
"""Immutable configuration for SQLAlchemy engine initialization.
|
|
22
|
+
|
|
23
|
+
``query_only=True`` enforces read-only access at the database level (via
|
|
24
|
+
a ``SET ... READ ONLY``-style statement issued on the SQLAlchemy
|
|
25
|
+
``"connect"`` event, not just by blocking ORM methods in Python) — this
|
|
26
|
+
assumes ``connection_url`` points directly at Postgres/MySQL, or at a
|
|
27
|
+
*session*-pooling-mode connection pooler (one physical backend connection
|
|
28
|
+
dedicated to one logical client for its whole lifetime). It does **not**
|
|
29
|
+
cover a *transaction*-pooling-mode pooler (e.g. PgBouncer in ``transaction``
|
|
30
|
+
mode): there, a single physical connection can be handed to a different,
|
|
31
|
+
unrelated logical client between transactions, and session-level state set
|
|
32
|
+
once at physical-connect time is not guaranteed to survive that handoff.
|
|
33
|
+
"""
|
|
22
34
|
|
|
23
35
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
24
36
|
|
|
@@ -27,7 +39,9 @@ class SqlDatabaseConfig(ResourceConfig):
|
|
|
27
39
|
default=True,
|
|
28
40
|
description=(
|
|
29
41
|
"When true, native database-enforced read-only access is required. "
|
|
30
|
-
"Set explicitly to false to allow writes."
|
|
42
|
+
"Set explicitly to false to allow writes. Assumes a direct connection "
|
|
43
|
+
"or a session-pooling-mode proxy — transaction-pooling-mode poolers "
|
|
44
|
+
"(e.g. PgBouncer 'transaction' mode) are out of scope; see class docstring."
|
|
31
45
|
),
|
|
32
46
|
)
|
|
33
47
|
worker_connection_env_var: str | None = Field(
|
|
@@ -148,11 +162,9 @@ class WorkerSqlConfig(BaseModel):
|
|
|
148
162
|
if config.worker_connection_env_var is not None:
|
|
149
163
|
payload["connection_env_var"] = config.worker_connection_env_var
|
|
150
164
|
else:
|
|
151
|
-
|
|
165
|
+
Logger.default_logger(logger_name=cls.__name__).warning(
|
|
152
166
|
"worker_connection_env_var is not set; falling back to raw DSN. "
|
|
153
|
-
"Credentials may be visible in scheduler logs."
|
|
154
|
-
UserWarning,
|
|
155
|
-
stacklevel=2,
|
|
167
|
+
"Credentials may be visible in scheduler logs."
|
|
156
168
|
)
|
|
157
169
|
payload["connection_url"] = config.connection_url
|
|
158
170
|
return cls(**payload)
|
|
@@ -238,22 +238,34 @@ class SqlModelRegistry:
|
|
|
238
238
|
md = self._get_or_create_metadata(ekey, schema)
|
|
239
239
|
md_lock = self._get_or_create_md_lock(md_key)
|
|
240
240
|
qname = self._qualified_key(schema, tname)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
241
|
+
|
|
242
|
+
# The whole reflect -> resolve-or-build -> cache-write sequence must
|
|
243
|
+
# be one atomic unit per md_key: reflection alone was already
|
|
244
|
+
# serialized via md_lock, but resolve-or-build and the cache write
|
|
245
|
+
# previously ran outside it, so two concurrent first-accesses for the
|
|
246
|
+
# same table could both pass "does a mapped class already exist?"
|
|
247
|
+
# before either finished building — each got a distinct Python class
|
|
248
|
+
# mapped to the same Table.
|
|
249
|
+
with md_lock:
|
|
250
|
+
cached = self._check_cache(model_key, refresh=False)
|
|
251
|
+
if cached is not None:
|
|
252
|
+
return cached
|
|
253
|
+
|
|
254
|
+
tbl = self._reflect_table(engine, md, qname=qname, tname=tname, schema=schema)
|
|
255
|
+
context = ModelBuildContext(
|
|
256
|
+
qname=qname,
|
|
257
|
+
tname=tname,
|
|
258
|
+
base_class=base_class,
|
|
259
|
+
module_label=module_label,
|
|
260
|
+
prefer_stable_names=prefer_stable_names,
|
|
261
|
+
ekey=ekey,
|
|
262
|
+
schema=schema,
|
|
263
|
+
missing_pk_warning_suffix="Ensure ORM mutation sequences validate bounds manually.",
|
|
264
|
+
)
|
|
265
|
+
model_cls = self._resolve_or_build_model(tbl, qname, context, target_label="DB target")
|
|
266
|
+
with self._lock:
|
|
267
|
+
self._model_cache[model_key] = model_cls
|
|
268
|
+
return model_cls
|
|
257
269
|
|
|
258
270
|
async def get_model_async(
|
|
259
271
|
self,
|
|
@@ -278,26 +290,33 @@ class SqlModelRegistry:
|
|
|
278
290
|
md = self._get_or_create_metadata(ekey, schema)
|
|
279
291
|
md_lock = await self._get_or_create_md_lock_async(md_key)
|
|
280
292
|
qname = self._qualified_key(schema, tname)
|
|
281
|
-
tbl = await self._reflect_table_async(
|
|
282
|
-
engine, md, md_lock, qname=qname, tname=tname, schema=schema
|
|
283
|
-
)
|
|
284
293
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
294
|
+
# See get_model()'s comment: the whole sequence must be one atomic
|
|
295
|
+
# unit per md_key, not just the reflection step.
|
|
296
|
+
async with md_lock:
|
|
297
|
+
cached = await self._check_cache_async(model_key, refresh=False)
|
|
298
|
+
if cached is not None:
|
|
299
|
+
return cached
|
|
300
|
+
|
|
301
|
+
tbl = await self._reflect_table_async(
|
|
302
|
+
engine, md, qname=qname, tname=tname, schema=schema
|
|
303
|
+
)
|
|
304
|
+
context = ModelBuildContext(
|
|
305
|
+
qname=qname,
|
|
306
|
+
tname=tname,
|
|
307
|
+
base_class=base_class,
|
|
308
|
+
module_label=module_label,
|
|
309
|
+
prefer_stable_names=prefer_stable_names,
|
|
310
|
+
ekey=ekey,
|
|
311
|
+
schema=schema,
|
|
312
|
+
missing_pk_warning_suffix="Ensure Async ORM validation sequences capture bounds correctly.",
|
|
313
|
+
)
|
|
314
|
+
model_cls = self._resolve_or_build_model(
|
|
315
|
+
tbl, qname, context, target_label="Async DB target"
|
|
316
|
+
)
|
|
317
|
+
async with self._async_lock:
|
|
318
|
+
self._model_cache[model_key] = model_cls
|
|
319
|
+
return model_cls
|
|
301
320
|
|
|
302
321
|
def _resolve_or_build_model(
|
|
303
322
|
self,
|
|
@@ -335,12 +354,15 @@ class SqlModelRegistry:
|
|
|
335
354
|
|
|
336
355
|
# Not a copy-pasted twin: sync md.reflect() vs conn.run_sync(md.reflect) is
|
|
337
356
|
# a genuine concurrency-primitive difference (see get_model()'s comment above).
|
|
357
|
+
# Both callers (get_model/get_model_async) already hold this md_key's
|
|
358
|
+
# md_lock/md_async_lock for the whole reflect -> resolve-or-build -> cache
|
|
359
|
+
# sequence, so no locking happens here — acquiring the same (non-reentrant)
|
|
360
|
+
# lock again inside would deadlock.
|
|
338
361
|
@staticmethod
|
|
339
362
|
# spaghetti-ignore[sync-async-duplication]: see above
|
|
340
363
|
def _reflect_table(
|
|
341
364
|
engine: Engine,
|
|
342
365
|
md: MetaData,
|
|
343
|
-
md_lock: threading.Lock,
|
|
344
366
|
*,
|
|
345
367
|
qname: str,
|
|
346
368
|
tname: str,
|
|
@@ -348,18 +370,14 @@ class SqlModelRegistry:
|
|
|
348
370
|
) -> Table | None:
|
|
349
371
|
tbl = md.tables.get(qname)
|
|
350
372
|
if tbl is None:
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if tbl is None:
|
|
354
|
-
md.reflect(bind=engine, only=[tname], schema=schema)
|
|
355
|
-
tbl = md.tables.get(qname)
|
|
373
|
+
md.reflect(bind=engine, only=[tname], schema=schema)
|
|
374
|
+
tbl = md.tables.get(qname)
|
|
356
375
|
return tbl
|
|
357
376
|
|
|
358
377
|
@staticmethod
|
|
359
378
|
async def _reflect_table_async(
|
|
360
379
|
engine: AsyncEngine,
|
|
361
380
|
md: MetaData,
|
|
362
|
-
md_lock: asyncio.Lock,
|
|
363
381
|
*,
|
|
364
382
|
qname: str,
|
|
365
383
|
tname: str,
|
|
@@ -367,12 +385,9 @@ class SqlModelRegistry:
|
|
|
367
385
|
) -> Table | None:
|
|
368
386
|
tbl = md.tables.get(qname)
|
|
369
387
|
if tbl is None:
|
|
370
|
-
async with
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
async with engine.begin() as conn:
|
|
374
|
-
await conn.run_sync(md.reflect, only=[tname], schema=schema)
|
|
375
|
-
tbl = md.tables.get(qname)
|
|
388
|
+
async with engine.begin() as conn:
|
|
389
|
+
await conn.run_sync(md.reflect, only=[tname], schema=schema)
|
|
390
|
+
tbl = md.tables.get(qname)
|
|
376
391
|
return tbl
|
|
377
392
|
|
|
378
393
|
def clear(self) -> None:
|
|
@@ -2,7 +2,13 @@ from boti_data.db import AsyncSqlDatabaseResource
|
|
|
2
2
|
|
|
3
3
|
from .core import DataGateway
|
|
4
4
|
from .policies import GatewayPolicies
|
|
5
|
-
from .requests import
|
|
5
|
+
from .requests import (
|
|
6
|
+
DataFrameOptions,
|
|
7
|
+
DataFrameParams,
|
|
8
|
+
ParquetLoadRequest,
|
|
9
|
+
SqlLoadRequest,
|
|
10
|
+
TableDescription,
|
|
11
|
+
)
|
|
6
12
|
|
|
7
13
|
__all__ = [
|
|
8
14
|
"AsyncSqlDatabaseResource",
|
|
@@ -12,4 +18,5 @@ __all__ = [
|
|
|
12
18
|
"GatewayPolicies",
|
|
13
19
|
"ParquetLoadRequest",
|
|
14
20
|
"SqlLoadRequest",
|
|
21
|
+
"TableDescription",
|
|
15
22
|
]
|
|
@@ -50,6 +50,7 @@ from .requests import (
|
|
|
50
50
|
BackendResource,
|
|
51
51
|
ResolvedExecutionMode,
|
|
52
52
|
ReturnType,
|
|
53
|
+
TableDescription,
|
|
53
54
|
)
|
|
54
55
|
|
|
55
56
|
_log = logging.getLogger(__name__)
|
|
@@ -244,6 +245,33 @@ class BackendStrategy(ABC):
|
|
|
244
245
|
"""
|
|
245
246
|
return self.estimate_result_size(ctx)
|
|
246
247
|
|
|
248
|
+
# -- Schema/row-count discovery ------------------------------------------
|
|
249
|
+
|
|
250
|
+
def describe(
|
|
251
|
+
self,
|
|
252
|
+
resource: BackendResource | None,
|
|
253
|
+
table: str,
|
|
254
|
+
*,
|
|
255
|
+
row_count_limit: int,
|
|
256
|
+
) -> TableDescription:
|
|
257
|
+
"""Cheaply inspect *table*'s schema and an approximate row count.
|
|
258
|
+
|
|
259
|
+
Raises ``NotImplementedError`` by default — only backends with a
|
|
260
|
+
cheap, bounded introspection path (no full-table load) should
|
|
261
|
+
override this.
|
|
262
|
+
"""
|
|
263
|
+
raise NotImplementedError(f"{self.name} backend does not support describe().")
|
|
264
|
+
|
|
265
|
+
async def describe_async(
|
|
266
|
+
self,
|
|
267
|
+
resource: BackendResource | None,
|
|
268
|
+
table: str,
|
|
269
|
+
*,
|
|
270
|
+
row_count_limit: int,
|
|
271
|
+
) -> TableDescription:
|
|
272
|
+
"""Async variant of :meth:`describe`. Default delegates to the sync method."""
|
|
273
|
+
return self.describe(resource, table, row_count_limit=row_count_limit)
|
|
274
|
+
|
|
247
275
|
|
|
248
276
|
# ---------------------------------------------------------------------------
|
|
249
277
|
# Registry
|
|
@@ -14,7 +14,7 @@ from boti.core.lifecycle import LifecycleCore
|
|
|
14
14
|
from boti.core.lifecycle_pickle import PicklableLifecycleCoreMixin
|
|
15
15
|
from boti_dask import async_safe_head, safe_head
|
|
16
16
|
|
|
17
|
-
from . import core_load
|
|
17
|
+
from . import core_describe, core_load
|
|
18
18
|
from ._gateway_init import (
|
|
19
19
|
GatewayInitOptions,
|
|
20
20
|
build_gateway_from_backend,
|
|
@@ -31,6 +31,7 @@ from .requests import (
|
|
|
31
31
|
ExecutionMode,
|
|
32
32
|
ResolvedReturnType,
|
|
33
33
|
ReturnType,
|
|
34
|
+
TableDescription,
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
|
|
@@ -246,6 +247,31 @@ class DataGateway(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
|
246
247
|
)
|
|
247
248
|
return await async_safe_head(frame, n=n, npartitions=npartitions)
|
|
248
249
|
|
|
250
|
+
def describe(
|
|
251
|
+
self, table: str | None = None, *, row_count_limit: int = 10_000
|
|
252
|
+
) -> TableDescription:
|
|
253
|
+
"""Cheaply inspect a table's schema and an approximate row count.
|
|
254
|
+
|
|
255
|
+
Runs a bounded introspection query — reflecting column dtypes without
|
|
256
|
+
loading any rows, then counting up to *row_count_limit* rows — instead
|
|
257
|
+
of requiring callers to know to self-impose ``limit=`` on a full
|
|
258
|
+
:meth:`load` before exploring an unfamiliar table.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
table: DB table name. Defaults to the table configured at
|
|
262
|
+
construction time (configured mode).
|
|
263
|
+
row_count_limit: Row count is exact up to this many rows; beyond
|
|
264
|
+
it, :attr:`TableDescription.row_count_is_exact` is ``False``
|
|
265
|
+
and :attr:`TableDescription.row_count` reports this cap.
|
|
266
|
+
"""
|
|
267
|
+
return core_describe.describe(self, table, row_count_limit=row_count_limit)
|
|
268
|
+
|
|
269
|
+
async def adescribe(
|
|
270
|
+
self, table: str | None = None, *, row_count_limit: int = 10_000
|
|
271
|
+
) -> TableDescription:
|
|
272
|
+
"""Async variant of :meth:`describe`."""
|
|
273
|
+
return await core_describe.describe_async(self, table, row_count_limit=row_count_limit)
|
|
274
|
+
|
|
249
275
|
def load(self, **options: Any) -> FrameResult:
|
|
250
276
|
"""Load data from the configured backend.
|
|
251
277
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""DataGateway.describe()/adescribe() implementations.
|
|
2
|
+
|
|
3
|
+
Split out of core.py purely for line-count headroom, mirroring core_load.py's
|
|
4
|
+
split: DataGateway's public methods keep their docstrings and stay callable
|
|
5
|
+
exactly as before, but their bodies move here as free functions taking the
|
|
6
|
+
gateway instance explicitly.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .requests import TableDescription
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .core import DataGateway
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_describe_table(gateway: DataGateway, table: str | None) -> str:
|
|
20
|
+
resolved = table or gateway._table
|
|
21
|
+
if resolved is None:
|
|
22
|
+
raise ValueError(
|
|
23
|
+
"describe()/adescribe() require table= (or a table configured at construction time)."
|
|
24
|
+
)
|
|
25
|
+
return resolved
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Not a copy-pasted twin: both are thin delegates to
|
|
29
|
+
# gateway._strategy.describe()/describe_async() after the identical
|
|
30
|
+
# table-resolution step; the real logic lives one layer down (sql_describe.py).
|
|
31
|
+
# spaghetti-ignore[sync-async-duplication]: see above
|
|
32
|
+
def describe(gateway: DataGateway, table: str | None, *, row_count_limit: int) -> TableDescription:
|
|
33
|
+
resolved_table = resolve_describe_table(gateway, table)
|
|
34
|
+
return gateway._strategy.describe(
|
|
35
|
+
gateway.resource, resolved_table, row_count_limit=row_count_limit
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def describe_async(
|
|
40
|
+
gateway: DataGateway, table: str | None, *, row_count_limit: int
|
|
41
|
+
) -> TableDescription:
|
|
42
|
+
resolved_table = resolve_describe_table(gateway, table)
|
|
43
|
+
return await gateway._strategy.describe_async(
|
|
44
|
+
gateway.resource, resolved_table, row_count_limit=row_count_limit
|
|
45
|
+
)
|
|
@@ -14,6 +14,7 @@ from boti_data.db import (
|
|
|
14
14
|
SqlPartitionedLoadRequest,
|
|
15
15
|
)
|
|
16
16
|
from boti_data.db.arrow_schema_mapper import (
|
|
17
|
+
arrow_table_from_pylist_with_fallback,
|
|
17
18
|
arrow_table_to_pandas,
|
|
18
19
|
build_arrow_schema_from_sqlalchemy_types,
|
|
19
20
|
rows_to_arrow_table,
|
|
@@ -61,10 +62,15 @@ def _arrow_table_from_sql_result(
|
|
|
61
62
|
*,
|
|
62
63
|
statement: Any | None,
|
|
63
64
|
) -> pa.Table:
|
|
64
|
-
|
|
65
|
+
# A raw-SQL-text load's statement is a SQLAlchemy TextClause, which has no
|
|
66
|
+
# .selected_columns (that's Select-only) -- treat it the same as no
|
|
67
|
+
# statement at all rather than letting the attribute lookup below crash.
|
|
68
|
+
if statement is None or not hasattr(statement, "selected_columns"):
|
|
65
69
|
if not rows:
|
|
66
70
|
return pa.table({name: [] for name in columns})
|
|
67
|
-
return
|
|
71
|
+
return arrow_table_from_pylist_with_fallback(
|
|
72
|
+
[dict(zip(columns, row)) for row in rows], columns
|
|
73
|
+
)
|
|
68
74
|
|
|
69
75
|
sql_types = [getattr(selected, "type", None) for selected in statement.selected_columns]
|
|
70
76
|
schema = build_arrow_schema_from_sqlalchemy_types(columns, sql_types)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass
|
|
3
4
|
from typing import Any, Literal, Union
|
|
4
5
|
|
|
5
6
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
@@ -207,3 +208,18 @@ class ParquetLoadRequest(BaseModel):
|
|
|
207
208
|
"Use as_pandas=True, return_type='arrow', or apply Dask head()/partitions explicitly."
|
|
208
209
|
)
|
|
209
210
|
return self
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass(frozen=True, slots=True)
|
|
214
|
+
class TableDescription:
|
|
215
|
+
"""Result of :meth:`~boti_data.gateway.core.DataGateway.describe`.
|
|
216
|
+
|
|
217
|
+
``row_count`` is exact when ``row_count_is_exact`` is ``True``; otherwise
|
|
218
|
+
it reports the ``row_count_limit`` cap that was hit, meaning "at least
|
|
219
|
+
this many rows" rather than the real total.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
table: str
|
|
223
|
+
columns: dict[str, str]
|
|
224
|
+
row_count: int
|
|
225
|
+
row_count_is_exact: bool
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Cheap SQL table introspection backing DataGateway.describe()/adescribe().
|
|
2
|
+
|
|
3
|
+
Split out of sql_strategy.py purely for line-count headroom, mirroring the
|
|
4
|
+
sql_size_estimation.py module split.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from boti_data.db.partitioned_planner import SqlPartitionPlanner
|
|
12
|
+
from boti_data.db.partitioned_statements import infer_meta_dtypes
|
|
13
|
+
|
|
14
|
+
from .loaders import reflect_and_select
|
|
15
|
+
from .requests import TableDescription
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def describe_table(
|
|
19
|
+
resource: Any,
|
|
20
|
+
table: str,
|
|
21
|
+
*,
|
|
22
|
+
row_count_limit: int,
|
|
23
|
+
) -> TableDescription:
|
|
24
|
+
"""Reflect *table*'s columns and count rows up to *row_count_limit*.
|
|
25
|
+
|
|
26
|
+
Never loads any actual row data: column dtypes come from SQLAlchemy
|
|
27
|
+
reflection (no query executed), and the row count is a single bounded
|
|
28
|
+
``COUNT(*)`` subquery capped at *row_count_limit* rows — safe to run
|
|
29
|
+
against an unfamiliar table without risking an accidental unbounded
|
|
30
|
+
query against a large production table.
|
|
31
|
+
"""
|
|
32
|
+
_model, statement = reflect_and_select(resource, table)
|
|
33
|
+
columns = infer_meta_dtypes(statement)
|
|
34
|
+
counted = SqlPartitionPlanner(resource).count_rows_up_to(statement, row_count_limit)
|
|
35
|
+
is_exact = counted <= row_count_limit
|
|
36
|
+
return TableDescription(
|
|
37
|
+
table=table,
|
|
38
|
+
columns=columns,
|
|
39
|
+
row_count=counted if is_exact else row_count_limit,
|
|
40
|
+
row_count_is_exact=is_exact,
|
|
41
|
+
)
|
|
@@ -34,7 +34,8 @@ from .frame_strategies import FrameResult
|
|
|
34
34
|
from .loaders import build_sql_partitioned_request, read_sql_async
|
|
35
35
|
from .normalization import build_partitioned_load_options
|
|
36
36
|
from .planning import InChunkPolicy
|
|
37
|
-
from .requests import BackendConfig, BackendName, BackendResource, SqlLoadRequest
|
|
37
|
+
from .requests import BackendConfig, BackendName, BackendResource, SqlLoadRequest, TableDescription
|
|
38
|
+
from .sql_describe import describe_table
|
|
38
39
|
from .sql_partitioned_exec import run_async_partitioned_request
|
|
39
40
|
from .sql_size_estimation import (
|
|
40
41
|
_DEFAULT_PARTITION_CHUNK_SIZE,
|
|
@@ -376,3 +377,32 @@ class SqlAlchemyStrategy(BackendStrategy):
|
|
|
376
377
|
return await asyncio.to_thread(self.estimate_result_size, ctx)
|
|
377
378
|
return await estimate_configured_async(ctx)
|
|
378
379
|
return await estimate_structured_async(ctx)
|
|
380
|
+
|
|
381
|
+
# -- Schema/row-count discovery ------------------------------------------
|
|
382
|
+
|
|
383
|
+
def describe(
|
|
384
|
+
self,
|
|
385
|
+
resource: BackendResource | None,
|
|
386
|
+
table: str,
|
|
387
|
+
*,
|
|
388
|
+
row_count_limit: int,
|
|
389
|
+
) -> TableDescription:
|
|
390
|
+
if resource is None:
|
|
391
|
+
raise ValueError("describe() requires an active SQL resource.")
|
|
392
|
+
return describe_table(resource, table, row_count_limit=row_count_limit)
|
|
393
|
+
|
|
394
|
+
# describe() is already a single quick, bounded query -- offloading it to
|
|
395
|
+
# a thread (like estimate_result_size_async's sync-resource branch above)
|
|
396
|
+
# is enough; no separate async reflection/count path is worth the
|
|
397
|
+
# duplication for something this cheap.
|
|
398
|
+
# spaghetti-ignore[sync-async-duplication]: see above
|
|
399
|
+
async def describe_async(
|
|
400
|
+
self,
|
|
401
|
+
resource: BackendResource | None,
|
|
402
|
+
table: str,
|
|
403
|
+
*,
|
|
404
|
+
row_count_limit: int,
|
|
405
|
+
) -> TableDescription:
|
|
406
|
+
return await asyncio.to_thread(
|
|
407
|
+
self.describe, resource, table, row_count_limit=row_count_limit
|
|
408
|
+
)
|
|
@@ -10,7 +10,7 @@ from boti_dask import DaskSession, dask_session
|
|
|
10
10
|
|
|
11
11
|
from boti_data.gateway import DataGateway
|
|
12
12
|
from boti_data.gateway.incremental import IncrementalLoadService
|
|
13
|
-
from boti_data.gateway.requests import BackendConfig
|
|
13
|
+
from boti_data.gateway.requests import BackendConfig, TableDescription
|
|
14
14
|
from boti_data.joins import indexed_left_join, left_join_frames
|
|
15
15
|
from boti_data.schema import DataFrameLike
|
|
16
16
|
from boti_data.watermark.incremental import IncrementalResult
|
|
@@ -273,6 +273,20 @@ class DataHelper(PicklableLifecycleCoreMixin, LifecycleCore):
|
|
|
273
273
|
async def apreview(self, statement: Any, model: Any, n: int = 5, **options: Any) -> Any:
|
|
274
274
|
return await self.gateway.aload(**self._build_preview_options(statement, model, n, options))
|
|
275
275
|
|
|
276
|
+
# Not a copy-pasted twin: pure delegate to self.gateway.describe()/
|
|
277
|
+
# adescribe() with identical kwargs, matching this class's other
|
|
278
|
+
# unflagged proxy pairs (load/aload, preview/apreview).
|
|
279
|
+
# spaghetti-ignore[sync-async-duplication]: see above
|
|
280
|
+
def describe(
|
|
281
|
+
self, table: str | None = None, *, row_count_limit: int = 10_000
|
|
282
|
+
) -> TableDescription:
|
|
283
|
+
return self.gateway.describe(table, row_count_limit=row_count_limit)
|
|
284
|
+
|
|
285
|
+
async def adescribe(
|
|
286
|
+
self, table: str | None = None, *, row_count_limit: int = 10_000
|
|
287
|
+
) -> TableDescription:
|
|
288
|
+
return await self.gateway.adescribe(table, row_count_limit=row_count_limit)
|
|
289
|
+
|
|
276
290
|
def load_period(self, dt_field: str, start: str, end: str, **kwargs: Any) -> Any:
|
|
277
291
|
return self.gateway.load_period(dt_field, start, end, **kwargs)
|
|
278
292
|
|