boti-data 1.3.2__tar.gz → 1.3.4__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 (120) hide show
  1. {boti_data-1.3.2 → boti_data-1.3.4}/PKG-INFO +1 -1
  2. {boti_data-1.3.2 → boti_data-1.3.4}/pyproject.toml +1 -1
  3. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/__init__.py +12 -0
  4. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/__init__.py +6 -0
  5. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/arrow_schema_mapper.py +31 -0
  6. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_config.py +3 -5
  7. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/__init__.py +8 -1
  8. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/_backend_strategies.py +28 -0
  9. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/core.py +27 -1
  10. boti_data-1.3.4/src/boti_data/gateway/core_describe.py +45 -0
  11. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/loaders.py +8 -2
  12. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/requests.py +16 -0
  13. boti_data-1.3.4/src/boti_data/gateway/sql_describe.py +41 -0
  14. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/sql_strategy.py +31 -1
  15. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/helper.py +15 -1
  16. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/__init__.py +6 -0
  17. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/__init__.py +12 -1
  18. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/sinks.py +3 -1
  19. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/sinks_common.py +106 -17
  20. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/sinks_parquet.py +39 -0
  21. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data.egg-info/PKG-INFO +1 -1
  22. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data.egg-info/SOURCES.txt +2 -0
  23. {boti_data-1.3.2 → boti_data-1.3.4}/README.md +0 -0
  24. {boti_data-1.3.2 → boti_data-1.3.4}/setup.cfg +0 -0
  25. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/arrow_schema.py +0 -0
  26. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/connection_catalog.py +0 -0
  27. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/datacube/__init__.py +0 -0
  28. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/datacube/artifact.py +0 -0
  29. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/datacube/base.py +0 -0
  30. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/datacube/contract.py +0 -0
  31. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/datacube/resource.py +0 -0
  32. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/dataset/__init__.py +0 -0
  33. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/dataset/hybrid.py +0 -0
  34. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/dataset/hybrid_support.py +0 -0
  35. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/engine_registry.py +0 -0
  36. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_execution.py +0 -0
  37. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_loader.py +0 -0
  38. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_planner.py +0 -0
  39. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_planner_async.py +0 -0
  40. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_planning_math.py +0 -0
  41. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_row_materialization.py +0 -0
  42. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_statements.py +0 -0
  43. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/partitioned_types.py +0 -0
  44. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_engine.py +0 -0
  45. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_manager.py +0 -0
  46. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_model_builder.py +0 -0
  47. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_model_module_injection.py +0 -0
  48. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_model_registry.py +0 -0
  49. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_model_registry_types.py +0 -0
  50. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_readonly.py +0 -0
  51. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_repository.py +0 -0
  52. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sql_resource.py +0 -0
  53. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/sqlalchemy_async.py +0 -0
  54. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/db/vector_search.py +0 -0
  55. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/enrichment/__init__.py +0 -0
  56. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/enrichment/async_enricher.py +0 -0
  57. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/enrichment/composite.py +0 -0
  58. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/enrichment/protocol.py +0 -0
  59. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/enrichment/specs.py +0 -0
  60. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/enrichment/transformers.py +0 -0
  61. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/field_map.py +0 -0
  62. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/__init__.py +0 -0
  63. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/arrow_kernels.py +0 -0
  64. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
  65. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
  66. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/arrow_kernels_string.py +0 -0
  67. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/backend_ops.py +0 -0
  68. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/expressions.py +0 -0
  69. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/handler.py +0 -0
  70. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/parquet_compiler.py +0 -0
  71. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/utils.py +0 -0
  72. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/filters/value_parsing.py +0 -0
  73. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/_factory.py +0 -0
  74. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/_gateway_init.py +0 -0
  75. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/_payloads.py +0 -0
  76. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/_series_filters.py +0 -0
  77. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/arrow_adapters.py +0 -0
  78. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/build_context.py +0 -0
  79. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/chunking.py +0 -0
  80. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/configured_load.py +0 -0
  81. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/core_load.py +0 -0
  82. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/datacube_strategy.py +0 -0
  83. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/frame_convert.py +0 -0
  84. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/frame_strategies.py +0 -0
  85. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/incremental.py +0 -0
  86. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/load_execution.py +0 -0
  87. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/load_request.py +0 -0
  88. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/normalization.py +0 -0
  89. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/parquet_strategy.py +0 -0
  90. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/planning.py +0 -0
  91. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/policies.py +0 -0
  92. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/post_process.py +0 -0
  93. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/return_type.py +0 -0
  94. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/select_cache.py +0 -0
  95. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/semi_join.py +0 -0
  96. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/sql_guard.py +0 -0
  97. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
  98. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/sql_size_estimation.py +0 -0
  99. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
  100. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/joins.py +0 -0
  101. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/config.py +0 -0
  102. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/discovery.py +0 -0
  103. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/file_info.py +0 -0
  104. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/filesystem.py +0 -0
  105. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/reader.py +0 -0
  106. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/reader_config.py +0 -0
  107. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/resource.py +0 -0
  108. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/parquet/schema_filters.py +0 -0
  109. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/base.py +0 -0
  110. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
  111. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/registry.py +0 -0
  112. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/pipelines/sinks_text.py +0 -0
  113. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/py.typed +0 -0
  114. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/schema.py +0 -0
  115. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/watermark/__init__.py +0 -0
  116. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/watermark/incremental.py +0 -0
  117. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data/watermark/store.py +0 -0
  118. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data.egg-info/dependency_links.txt +0 -0
  119. {boti_data-1.3.2 → boti_data-1.3.4}/src/boti_data.egg-info/requires.txt +0 -0
  120. {boti_data-1.3.2 → boti_data-1.3.4}/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.3.2
3
+ Version: 1.3.4
4
4
  Summary: Data infrastructure for the Boti ecosystem
5
5
  Author: Luis Valverde
6
6
  Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "boti-data"
3
- version = "1.3.2"
3
+ version = "1.3.4"
4
4
  description = "Data infrastructure for the Boti ecosystem"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -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
- warnings.warn(
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 DataFrameOptions, DataFrameParams, ParquetLoadRequest, SqlLoadRequest
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
- if statement is None:
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 pa.Table.from_pylist([dict(zip(columns, row)) for row in rows])
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 CsvSink, CsvSinkConfig, JsonlSink, JsonlSinkConfig, ParquetSink, SinkWriteResult
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 not partition_on:
82
- return frame
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
- if missing:
91
- raise ValueError(
92
- f"{sink_name} partition columns must already exist in the loaded frame or be derivable "
93
- f"from date_field. Missing columns: {missing!r}."
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,60 @@ def _rm_recursive(fs: fsspec.AbstractFileSystem, path: str) -> None:
109
146
  fs.rm(path)
110
147
 
111
148
 
149
+ def _rebase_staged_path(
150
+ fs: fsspec.AbstractFileSystem,
151
+ path: str,
152
+ staging_path: str,
153
+ target_root: str,
154
+ ) -> str:
155
+ """Rewrite *path* from the staging tree to the target tree.
156
+
157
+ ``glob()``/``find()`` on most fsspec backends (S3, memory, ...) return
158
+ protocol-stripped paths (``bucket/key``) even when queried with a
159
+ scheme-prefixed directory (``s3://bucket/key``) -- but *staging_path*/
160
+ *target_root* here are whatever the caller originally passed in, which
161
+ for a ``Datasources``-resolved destination is scheme-prefixed. A naive
162
+ ``path.replace(staging_path, target_root)`` then silently no-ops (no
163
+ substring match, string returned unchanged) instead of raising, so both
164
+ sides are normalized through ``_strip_protocol()`` first to guarantee the
165
+ replace actually matches regardless of which form *path* arrived in.
166
+ """
167
+ normalized_path = fs._strip_protocol(path)
168
+ normalized_staging = fs._strip_protocol(staging_path)
169
+ normalized_target = fs._strip_protocol(target_root)
170
+ return normalized_path.replace(normalized_staging, normalized_target, 1)
171
+
172
+
173
+ def _move_staged_files(
174
+ fs: fsspec.AbstractFileSystem,
175
+ staged_files: Sequence[str],
176
+ staging_path: str,
177
+ target_root: str,
178
+ ) -> None:
179
+ """Move each enumerated staged file individually into *target_root*.
180
+
181
+ Deliberately avoids a single recursive ``fs.mv(staging_path, target_root,
182
+ recursive=True)``. On prefix-only S3-compatible backends (e.g. MinIO),
183
+ ``expand_path(recursive=True)`` can return the bare directory prefix
184
+ itself as a phantom "file" alongside the real objects -- no object
185
+ actually exists at that literal key -- and ``mv()`` forces
186
+ ``on_error="raise"`` on the resulting copy, so that one phantom entry
187
+ 404s the entire swap even though every real file would have copied fine.
188
+ Moving the already-known file list one by one never expands a directory,
189
+ so the phantom entry never gets a chance to appear.
190
+ """
191
+ for staged_file in staged_files:
192
+ destination = _rebase_staged_path(fs, staged_file, staging_path, target_root)
193
+ parent = destination.rsplit("/", 1)[0]
194
+ if parent and parent != destination:
195
+ fs.makedirs(parent, exist_ok=True)
196
+ fs.mv(staged_file, destination)
197
+ if fs.exists(staging_path):
198
+ # Empty leftover directory structure (local-disk backends only --
199
+ # prefix-only stores have nothing left here once every file moved).
200
+ _rm_recursive(fs, staging_path)
201
+
202
+
112
203
  def _write_with_staging(
113
204
  *,
114
205
  fs: fsspec.AbstractFileSystem,
@@ -134,20 +225,18 @@ def _write_with_staging(
134
225
  _rm_recursive(fs, staging_path)
135
226
 
136
227
  files = write_fn(staging_path)
228
+ target_root = target_path.rstrip("/")
137
229
 
138
230
  try:
139
231
  _rm_recursive(fs, target_path)
140
- try:
141
- fs.mv(staging_path, target_path, recursive=True)
142
- except TypeError:
143
- fs.mv(staging_path, target_path)
232
+ _move_staged_files(fs, files, staging_path, target_root)
144
233
  except Exception as exc:
145
234
  raise RuntimeError(
146
235
  f"Failed to swap staged sink output into {target_path!r}. "
147
236
  f"The newly written data is intact at {staging_path!r}."
148
237
  ) from exc
149
238
 
150
- return [file.replace(staging_path, target_path.rstrip("/"), 1) for file in files]
239
+ return [_rebase_staged_path(fs, file, staging_path, target_root) for file in files]
151
240
 
152
241
 
153
242
  @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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boti-data
3
- Version: 1.3.2
3
+ Version: 1.3.4
4
4
  Summary: Data infrastructure for the Boti ecosystem
5
5
  Author: Luis Valverde
6
6
  Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
@@ -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