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.
Files changed (120) hide show
  1. {boti_data-1.3.1 → boti_data-1.3.3}/PKG-INFO +1 -1
  2. {boti_data-1.3.1 → boti_data-1.3.3}/pyproject.toml +1 -1
  3. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/__init__.py +12 -0
  4. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/__init__.py +6 -0
  5. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/arrow_schema_mapper.py +31 -0
  6. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_config.py +19 -7
  7. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_registry.py +63 -48
  8. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/__init__.py +8 -1
  9. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_backend_strategies.py +28 -0
  10. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/core.py +27 -1
  11. boti_data-1.3.3/src/boti_data/gateway/core_describe.py +45 -0
  12. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/loaders.py +8 -2
  13. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/requests.py +16 -0
  14. boti_data-1.3.3/src/boti_data/gateway/sql_describe.py +41 -0
  15. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_strategy.py +31 -1
  16. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/helper.py +15 -1
  17. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/__init__.py +6 -0
  18. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/__init__.py +12 -1
  19. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks.py +3 -1
  20. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_common.py +82 -17
  21. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_parquet.py +39 -0
  22. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/PKG-INFO +1 -1
  23. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/SOURCES.txt +2 -0
  24. {boti_data-1.3.1 → boti_data-1.3.3}/README.md +0 -0
  25. {boti_data-1.3.1 → boti_data-1.3.3}/setup.cfg +0 -0
  26. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/arrow_schema.py +0 -0
  27. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/connection_catalog.py +0 -0
  28. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/__init__.py +0 -0
  29. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/artifact.py +0 -0
  30. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/base.py +0 -0
  31. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/contract.py +0 -0
  32. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/datacube/resource.py +0 -0
  33. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/dataset/__init__.py +0 -0
  34. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/dataset/hybrid.py +0 -0
  35. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/dataset/hybrid_support.py +0 -0
  36. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/engine_registry.py +0 -0
  37. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_execution.py +0 -0
  38. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_loader.py +0 -0
  39. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_planner.py +0 -0
  40. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_planner_async.py +0 -0
  41. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_planning_math.py +0 -0
  42. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_row_materialization.py +0 -0
  43. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_statements.py +0 -0
  44. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/partitioned_types.py +0 -0
  45. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_engine.py +0 -0
  46. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_manager.py +0 -0
  47. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_builder.py +0 -0
  48. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_module_injection.py +0 -0
  49. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_model_registry_types.py +0 -0
  50. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_readonly.py +0 -0
  51. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_repository.py +0 -0
  52. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sql_resource.py +0 -0
  53. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/sqlalchemy_async.py +0 -0
  54. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/db/vector_search.py +0 -0
  55. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/__init__.py +0 -0
  56. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/async_enricher.py +0 -0
  57. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/composite.py +0 -0
  58. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/protocol.py +0 -0
  59. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/specs.py +0 -0
  60. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/enrichment/transformers.py +0 -0
  61. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/field_map.py +0 -0
  62. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/__init__.py +0 -0
  63. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels.py +0 -0
  64. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
  65. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
  66. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/arrow_kernels_string.py +0 -0
  67. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/backend_ops.py +0 -0
  68. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/expressions.py +0 -0
  69. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/handler.py +0 -0
  70. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/parquet_compiler.py +0 -0
  71. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/utils.py +0 -0
  72. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/filters/value_parsing.py +0 -0
  73. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_factory.py +0 -0
  74. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_gateway_init.py +0 -0
  75. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_payloads.py +0 -0
  76. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/_series_filters.py +0 -0
  77. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/arrow_adapters.py +0 -0
  78. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/build_context.py +0 -0
  79. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/chunking.py +0 -0
  80. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/configured_load.py +0 -0
  81. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/core_load.py +0 -0
  82. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/datacube_strategy.py +0 -0
  83. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/frame_convert.py +0 -0
  84. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/frame_strategies.py +0 -0
  85. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/incremental.py +0 -0
  86. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/load_execution.py +0 -0
  87. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/load_request.py +0 -0
  88. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/normalization.py +0 -0
  89. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/parquet_strategy.py +0 -0
  90. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/planning.py +0 -0
  91. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/policies.py +0 -0
  92. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/post_process.py +0 -0
  93. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/return_type.py +0 -0
  94. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/select_cache.py +0 -0
  95. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/semi_join.py +0 -0
  96. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_guard.py +0 -0
  97. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
  98. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_size_estimation.py +0 -0
  99. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
  100. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/joins.py +0 -0
  101. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/config.py +0 -0
  102. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/discovery.py +0 -0
  103. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/file_info.py +0 -0
  104. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/filesystem.py +0 -0
  105. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/reader.py +0 -0
  106. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/reader_config.py +0 -0
  107. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/resource.py +0 -0
  108. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/parquet/schema_filters.py +0 -0
  109. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/base.py +0 -0
  110. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
  111. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/registry.py +0 -0
  112. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/pipelines/sinks_text.py +0 -0
  113. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/py.typed +0 -0
  114. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/schema.py +0 -0
  115. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/watermark/__init__.py +0 -0
  116. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/watermark/incremental.py +0 -0
  117. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data/watermark/store.py +0 -0
  118. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/dependency_links.txt +0 -0
  119. {boti_data-1.3.1 → boti_data-1.3.3}/src/boti_data.egg-info/requires.txt +0 -0
  120. {boti_data-1.3.1 → boti_data-1.3.3}/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.1
3
+ Version: 1.3.3
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.1"
3
+ version = "1.3.3"
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
@@ -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
- warnings.warn(
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
- tbl = self._reflect_table(engine, md, md_lock, qname=qname, tname=tname, schema=schema)
242
-
243
- context = ModelBuildContext(
244
- qname=qname,
245
- tname=tname,
246
- base_class=base_class,
247
- module_label=module_label,
248
- prefer_stable_names=prefer_stable_names,
249
- ekey=ekey,
250
- schema=schema,
251
- missing_pk_warning_suffix="Ensure ORM mutation sequences validate bounds manually.",
252
- )
253
- model_cls = self._resolve_or_build_model(tbl, qname, context, target_label="DB target")
254
- with self._lock:
255
- self._model_cache[model_key] = model_cls
256
- return model_cls
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
- context = ModelBuildContext(
286
- qname=qname,
287
- tname=tname,
288
- base_class=base_class,
289
- module_label=module_label,
290
- prefer_stable_names=prefer_stable_names,
291
- ekey=ekey,
292
- schema=schema,
293
- missing_pk_warning_suffix="Ensure Async ORM validation sequences capture bounds correctly.",
294
- )
295
- model_cls = self._resolve_or_build_model(
296
- tbl, qname, context, target_label="Async DB target"
297
- )
298
- async with self._async_lock:
299
- self._model_cache[model_key] = model_cls
300
- return model_cls
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
- with md_lock:
352
- tbl = md.tables.get(qname)
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 md_lock:
371
- tbl = md.tables.get(qname)
372
- if tbl is None:
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 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