boti-data 1.3.2__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.2 → boti_data-1.3.3}/PKG-INFO +1 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/pyproject.toml +1 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/__init__.py +12 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/__init__.py +6 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/arrow_schema_mapper.py +31 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_config.py +3 -5
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/__init__.py +8 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/_backend_strategies.py +28 -0
- {boti_data-1.3.2 → 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.2 → boti_data-1.3.3}/src/boti_data/gateway/loaders.py +8 -2
- {boti_data-1.3.2 → 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.2 → boti_data-1.3.3}/src/boti_data/gateway/sql_strategy.py +31 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/helper.py +15 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/__init__.py +6 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/__init__.py +12 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/sinks.py +3 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_common.py +82 -17
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_parquet.py +39 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data.egg-info/PKG-INFO +1 -1
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data.egg-info/SOURCES.txt +2 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/README.md +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/setup.cfg +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/arrow_schema.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/connection_catalog.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/datacube/__init__.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/datacube/artifact.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/datacube/base.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/datacube/contract.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/datacube/resource.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/dataset/__init__.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/dataset/hybrid.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/dataset/hybrid_support.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/engine_registry.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_execution.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_loader.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_planner.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_planner_async.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_planning_math.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_row_materialization.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_statements.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/partitioned_types.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_engine.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_manager.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_model_builder.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_model_module_injection.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_model_registry.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_model_registry_types.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_readonly.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_repository.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sql_resource.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/sqlalchemy_async.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/db/vector_search.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/enrichment/__init__.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/enrichment/async_enricher.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/enrichment/composite.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/enrichment/protocol.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/enrichment/specs.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/enrichment/transformers.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/field_map.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/__init__.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_string.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/backend_ops.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/expressions.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/handler.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/parquet_compiler.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/utils.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/filters/value_parsing.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/_factory.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/_gateway_init.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/_payloads.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/_series_filters.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/arrow_adapters.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/build_context.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/chunking.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/configured_load.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/core_load.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/datacube_strategy.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/frame_convert.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/frame_strategies.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/incremental.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/load_execution.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/load_request.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/normalization.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/parquet_strategy.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/planning.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/policies.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/post_process.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/return_type.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/select_cache.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/semi_join.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/sql_guard.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/sql_size_estimation.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/joins.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/config.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/discovery.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/file_info.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/filesystem.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/reader.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/reader_config.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/resource.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/parquet/schema_filters.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/base.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/registry.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_text.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/py.typed +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/schema.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/watermark/__init__.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/watermark/incremental.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data/watermark/store.py +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data.egg-info/dependency_links.txt +0 -0
- {boti_data-1.3.2 → boti_data-1.3.3}/src/boti_data.egg-info/requires.txt +0 -0
- {boti_data-1.3.2 → 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
|
|
@@ -162,11 +162,9 @@ class WorkerSqlConfig(BaseModel):
|
|
|
162
162
|
if config.worker_connection_env_var is not None:
|
|
163
163
|
payload["connection_env_var"] = config.worker_connection_env_var
|
|
164
164
|
else:
|
|
165
|
-
|
|
165
|
+
Logger.default_logger(logger_name=cls.__name__).warning(
|
|
166
166
|
"worker_connection_env_var is not set; falling back to raw DSN. "
|
|
167
|
-
"Credentials may be visible in scheduler logs."
|
|
168
|
-
UserWarning,
|
|
169
|
-
stacklevel=2,
|
|
167
|
+
"Credentials may be visible in scheduler logs."
|
|
170
168
|
)
|
|
171
169
|
payload["connection_url"] = config.connection_url
|
|
172
170
|
return cls(**payload)
|
|
@@ -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
|
|
|
@@ -19,6 +19,12 @@ __all__ = ["ParquetDataConfig", "ParquetDataResource", "ParquetReader"]
|
|
|
19
19
|
_LAZY = {"ParquetReader": ("boti_data.parquet.reader", "ParquetReader")}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
# Deliberately identical to the __getattr__ in boti_data/__init__.py and
|
|
23
|
+
# boti_data/db/__init__.py -- this is PEP 562's own documented module-level
|
|
24
|
+
# lazy-import boilerplate. Each copy needs its own private
|
|
25
|
+
# _LAZY dict/globals()/__name__, so sharing an implementation would need an
|
|
26
|
+
# awkward globals()-passing helper for no real gain over these 6 lines.
|
|
27
|
+
# spaghetti-ignore[duplicate-function-body]: see above
|
|
22
28
|
def __getattr__(name: str) -> Any:
|
|
23
29
|
if name in _LAZY:
|
|
24
30
|
module_name, attr = _LAZY[name]
|
|
@@ -3,7 +3,16 @@ from __future__ import annotations
|
|
|
3
3
|
from .base import SinkPipeline
|
|
4
4
|
from .parquet_pipeline import ParquetMaterializationResult, ParquetPipeline
|
|
5
5
|
from .registry import SinkRegistry, available_sinks, create_sink, register_sink
|
|
6
|
-
from .sinks import
|
|
6
|
+
from .sinks import (
|
|
7
|
+
CsvSink,
|
|
8
|
+
CsvSinkConfig,
|
|
9
|
+
JsonlSink,
|
|
10
|
+
JsonlSinkConfig,
|
|
11
|
+
ParquetSink,
|
|
12
|
+
SinkWriteResult,
|
|
13
|
+
awrite_parquet,
|
|
14
|
+
write_parquet,
|
|
15
|
+
)
|
|
7
16
|
|
|
8
17
|
__all__ = [
|
|
9
18
|
"CsvSink",
|
|
@@ -17,6 +26,8 @@ __all__ = [
|
|
|
17
26
|
"SinkPipeline",
|
|
18
27
|
"SinkWriteResult",
|
|
19
28
|
"available_sinks",
|
|
29
|
+
"awrite_parquet",
|
|
20
30
|
"create_sink",
|
|
21
31
|
"register_sink",
|
|
32
|
+
"write_parquet",
|
|
22
33
|
]
|
|
@@ -16,7 +16,7 @@ from boti_data.pipelines.sinks_common import (
|
|
|
16
16
|
prepare_partitioned_frame,
|
|
17
17
|
to_dask_frame,
|
|
18
18
|
)
|
|
19
|
-
from boti_data.pipelines.sinks_parquet import ParquetSink
|
|
19
|
+
from boti_data.pipelines.sinks_parquet import ParquetSink, awrite_parquet, write_parquet
|
|
20
20
|
from boti_data.pipelines.sinks_text import CsvSink, CsvSinkConfig, JsonlSink, JsonlSinkConfig
|
|
21
21
|
|
|
22
22
|
__all__ = [
|
|
@@ -29,6 +29,8 @@ __all__ = [
|
|
|
29
29
|
"ParquetSink",
|
|
30
30
|
"PipelineSink",
|
|
31
31
|
"SinkWriteResult",
|
|
32
|
+
"awrite_parquet",
|
|
32
33
|
"prepare_partitioned_frame",
|
|
33
34
|
"to_dask_frame",
|
|
35
|
+
"write_parquet",
|
|
34
36
|
]
|
|
@@ -71,6 +71,43 @@ def _derive_partition_date_column(
|
|
|
71
71
|
return frame
|
|
72
72
|
|
|
73
73
|
|
|
74
|
+
def _validate_meta_matches_real_dtypes(frame: dd.DataFrame, *, sink_name: str) -> None:
|
|
75
|
+
"""Guard against dask-expr's meta/real dtype divergence (wishlist #2/#7).
|
|
76
|
+
|
|
77
|
+
A bare `dd.to_datetime()`/`pd.to_numeric()` reassignment or an unannotated
|
|
78
|
+
`map_partitions()` call anywhere upstream in a pipeline -- not just inside
|
|
79
|
+
this module's own partition_date derivation -- can silently corrupt an
|
|
80
|
+
*unrelated* column's synthetic `_meta_nonempty` sample without touching
|
|
81
|
+
the real per-partition data. Left unchecked, that divergence doesn't fail
|
|
82
|
+
here: it surfaces later as an opaque pyarrow `ArrowInvalid` at write time
|
|
83
|
+
with nothing pointing back to the real cause. Catch it against one real
|
|
84
|
+
computed partition before any sink commits to a write.
|
|
85
|
+
"""
|
|
86
|
+
meta_dtypes = frame.dtypes
|
|
87
|
+
try:
|
|
88
|
+
real_dtypes = frame.head(1, npartitions=-1, compute=True).dtypes
|
|
89
|
+
except ValueError:
|
|
90
|
+
return # empty frame -- nothing real to validate meta against
|
|
91
|
+
mismatched = {
|
|
92
|
+
column: (meta_dtypes[column], real_dtypes[column])
|
|
93
|
+
for column in real_dtypes.index
|
|
94
|
+
if meta_dtypes[column] != real_dtypes[column]
|
|
95
|
+
}
|
|
96
|
+
if not mismatched:
|
|
97
|
+
return
|
|
98
|
+
details = ", ".join(
|
|
99
|
+
f"{column!r} (meta={meta!s}, real={real!s})" for column, (meta, real) in mismatched.items()
|
|
100
|
+
)
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"{sink_name} detected a dask meta/real dtype mismatch before write: {details}. "
|
|
103
|
+
"This usually means a column elsewhere in the pipeline was reassigned via a bare "
|
|
104
|
+
"dd.to_datetime()/pd.to_numeric() call or an unannotated map_partitions(), which can "
|
|
105
|
+
"silently corrupt _meta_nonempty for unrelated columns. Pass an explicit meta= to "
|
|
106
|
+
"map_partitions() (or route the coercion through it) instead of reassigning via a "
|
|
107
|
+
"bare top-level pandas/dask function."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
74
111
|
def prepare_partitioned_frame(
|
|
75
112
|
frame: dd.DataFrame,
|
|
76
113
|
*,
|
|
@@ -78,20 +115,20 @@ def prepare_partitioned_frame(
|
|
|
78
115
|
date_field: str | None,
|
|
79
116
|
sink_name: str,
|
|
80
117
|
) -> dd.DataFrame:
|
|
81
|
-
if
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
partition_columns = list(partition_on)
|
|
85
|
-
missing = [name for name in partition_columns if name not in frame.columns]
|
|
86
|
-
if "partition_date" in missing:
|
|
87
|
-
frame = _derive_partition_date_column(frame, date_field, sink_name)
|
|
118
|
+
if partition_on:
|
|
119
|
+
partition_columns = list(partition_on)
|
|
88
120
|
missing = [name for name in partition_columns if name not in frame.columns]
|
|
121
|
+
if "partition_date" in missing:
|
|
122
|
+
frame = _derive_partition_date_column(frame, date_field, sink_name)
|
|
123
|
+
missing = [name for name in partition_columns if name not in frame.columns]
|
|
89
124
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
if missing:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{sink_name} partition columns must already exist in the loaded frame or be "
|
|
128
|
+
f"derivable from date_field. Missing columns: {missing!r}."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
_validate_meta_matches_real_dtypes(frame, sink_name=sink_name)
|
|
95
132
|
return frame
|
|
96
133
|
|
|
97
134
|
|
|
@@ -109,6 +146,36 @@ def _rm_recursive(fs: fsspec.AbstractFileSystem, path: str) -> None:
|
|
|
109
146
|
fs.rm(path)
|
|
110
147
|
|
|
111
148
|
|
|
149
|
+
def _move_staged_files(
|
|
150
|
+
fs: fsspec.AbstractFileSystem,
|
|
151
|
+
staged_files: Sequence[str],
|
|
152
|
+
staging_path: str,
|
|
153
|
+
target_root: str,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Move each enumerated staged file individually into *target_root*.
|
|
156
|
+
|
|
157
|
+
Deliberately avoids a single recursive ``fs.mv(staging_path, target_root,
|
|
158
|
+
recursive=True)``. On prefix-only S3-compatible backends (e.g. MinIO),
|
|
159
|
+
``expand_path(recursive=True)`` can return the bare directory prefix
|
|
160
|
+
itself as a phantom "file" alongside the real objects -- no object
|
|
161
|
+
actually exists at that literal key -- and ``mv()`` forces
|
|
162
|
+
``on_error="raise"`` on the resulting copy, so that one phantom entry
|
|
163
|
+
404s the entire swap even though every real file would have copied fine.
|
|
164
|
+
Moving the already-known file list one by one never expands a directory,
|
|
165
|
+
so the phantom entry never gets a chance to appear.
|
|
166
|
+
"""
|
|
167
|
+
for staged_file in staged_files:
|
|
168
|
+
destination = staged_file.replace(staging_path, target_root, 1)
|
|
169
|
+
parent = destination.rsplit("/", 1)[0]
|
|
170
|
+
if parent and parent != destination:
|
|
171
|
+
fs.makedirs(parent, exist_ok=True)
|
|
172
|
+
fs.mv(staged_file, destination)
|
|
173
|
+
if fs.exists(staging_path):
|
|
174
|
+
# Empty leftover directory structure (local-disk backends only --
|
|
175
|
+
# prefix-only stores have nothing left here once every file moved).
|
|
176
|
+
_rm_recursive(fs, staging_path)
|
|
177
|
+
|
|
178
|
+
|
|
112
179
|
def _write_with_staging(
|
|
113
180
|
*,
|
|
114
181
|
fs: fsspec.AbstractFileSystem,
|
|
@@ -134,20 +201,18 @@ def _write_with_staging(
|
|
|
134
201
|
_rm_recursive(fs, staging_path)
|
|
135
202
|
|
|
136
203
|
files = write_fn(staging_path)
|
|
204
|
+
target_root = target_path.rstrip("/")
|
|
137
205
|
|
|
138
206
|
try:
|
|
139
207
|
_rm_recursive(fs, target_path)
|
|
140
|
-
|
|
141
|
-
fs.mv(staging_path, target_path, recursive=True)
|
|
142
|
-
except TypeError:
|
|
143
|
-
fs.mv(staging_path, target_path)
|
|
208
|
+
_move_staged_files(fs, files, staging_path, target_root)
|
|
144
209
|
except Exception as exc:
|
|
145
210
|
raise RuntimeError(
|
|
146
211
|
f"Failed to swap staged sink output into {target_path!r}. "
|
|
147
212
|
f"The newly written data is intact at {staging_path!r}."
|
|
148
213
|
) from exc
|
|
149
214
|
|
|
150
|
-
return [file.replace(staging_path,
|
|
215
|
+
return [file.replace(staging_path, target_root, 1) for file in files]
|
|
151
216
|
|
|
152
217
|
|
|
153
218
|
@dataclass(slots=True)
|
|
@@ -125,3 +125,42 @@ class ParquetSink(_AsyncWriteViaThreadMixin, PicklableLifecycleCoreMixin, Lifecy
|
|
|
125
125
|
@staticmethod
|
|
126
126
|
def _restore_protocol(path: str) -> str:
|
|
127
127
|
return str(path)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Not a copy-pasted twin: `with`/`async with` + sink.write()/await sink.awrite()
|
|
131
|
+
# is the irreducible sync/async difference; there's no further shared logic to
|
|
132
|
+
# hoist for what's just a construct+write+close convenience.
|
|
133
|
+
# spaghetti-ignore[sync-async-duplication]: see above
|
|
134
|
+
def write_parquet(
|
|
135
|
+
destination: ParquetDestination,
|
|
136
|
+
frame: FrameResult,
|
|
137
|
+
*,
|
|
138
|
+
partition_on: Sequence[str] | None = ("partition_date",),
|
|
139
|
+
**kwargs: Any,
|
|
140
|
+
) -> SinkWriteResult:
|
|
141
|
+
"""One-shot convenience: construct a ParquetSink, write *frame*, and close it.
|
|
142
|
+
|
|
143
|
+
A ``ParquetSink`` is typically built, used for exactly one ``write()``,
|
|
144
|
+
and discarded — forgetting ``with ParquetSink(...) as sink:`` leaks the
|
|
145
|
+
underlying ``DataGateway``/``ParquetDataResource``, surfaced only as a
|
|
146
|
+
background GC warning well after the fact, not at the call site. Callers
|
|
147
|
+
who genuinely reuse one ``ParquetSink`` instance across multiple writes
|
|
148
|
+
should keep using the class directly instead.
|
|
149
|
+
|
|
150
|
+
``**kwargs`` are forwarded to :meth:`ParquetSink.write` (``date_field``,
|
|
151
|
+
``write_index``, ``overwrite``, ``persist``).
|
|
152
|
+
"""
|
|
153
|
+
with ParquetSink(destination, partition_on=partition_on) as sink:
|
|
154
|
+
return sink.write(frame, **kwargs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def awrite_parquet(
|
|
158
|
+
destination: ParquetDestination,
|
|
159
|
+
frame: FrameResult,
|
|
160
|
+
*,
|
|
161
|
+
partition_on: Sequence[str] | None = ("partition_date",),
|
|
162
|
+
**kwargs: Any,
|
|
163
|
+
) -> SinkWriteResult:
|
|
164
|
+
"""Async variant of :func:`write_parquet`."""
|
|
165
|
+
async with ParquetSink(destination, partition_on=partition_on) as sink:
|
|
166
|
+
return await sink.awrite(frame, **kwargs)
|
|
@@ -72,6 +72,7 @@ src/boti_data/gateway/build_context.py
|
|
|
72
72
|
src/boti_data/gateway/chunking.py
|
|
73
73
|
src/boti_data/gateway/configured_load.py
|
|
74
74
|
src/boti_data/gateway/core.py
|
|
75
|
+
src/boti_data/gateway/core_describe.py
|
|
75
76
|
src/boti_data/gateway/core_load.py
|
|
76
77
|
src/boti_data/gateway/datacube_strategy.py
|
|
77
78
|
src/boti_data/gateway/frame_convert.py
|
|
@@ -89,6 +90,7 @@ src/boti_data/gateway/requests.py
|
|
|
89
90
|
src/boti_data/gateway/return_type.py
|
|
90
91
|
src/boti_data/gateway/select_cache.py
|
|
91
92
|
src/boti_data/gateway/semi_join.py
|
|
93
|
+
src/boti_data/gateway/sql_describe.py
|
|
92
94
|
src/boti_data/gateway/sql_guard.py
|
|
93
95
|
src/boti_data/gateway/sql_partitioned_exec.py
|
|
94
96
|
src/boti_data/gateway/sql_size_estimation.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|