boti-data 1.3.7__tar.gz → 1.3.8__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.7 → boti_data-1.3.8}/PKG-INFO +1 -1
  2. {boti_data-1.3.7 → boti_data-1.3.8}/pyproject.toml +1 -1
  3. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/protocol.py +7 -4
  4. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/transformers.py +28 -7
  5. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/schema_filters.py +81 -19
  6. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks_common.py +62 -0
  7. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks_parquet.py +1 -0
  8. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/PKG-INFO +1 -1
  9. {boti_data-1.3.7 → boti_data-1.3.8}/README.md +0 -0
  10. {boti_data-1.3.7 → boti_data-1.3.8}/setup.cfg +0 -0
  11. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/__init__.py +0 -0
  12. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/arrow_schema.py +0 -0
  13. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/connection_catalog.py +0 -0
  14. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/__init__.py +0 -0
  15. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/artifact.py +0 -0
  16. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/base.py +0 -0
  17. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/contract.py +0 -0
  18. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/resource.py +0 -0
  19. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/dataset/__init__.py +0 -0
  20. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/dataset/hybrid.py +0 -0
  21. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/dataset/hybrid_support.py +0 -0
  22. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/__init__.py +0 -0
  23. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/arrow_schema_mapper.py +0 -0
  24. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/engine_registry.py +0 -0
  25. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_execution.py +0 -0
  26. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_loader.py +0 -0
  27. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_planner.py +0 -0
  28. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_planner_async.py +0 -0
  29. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_planning_math.py +0 -0
  30. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_row_materialization.py +0 -0
  31. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_statements.py +0 -0
  32. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_types.py +0 -0
  33. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_config.py +0 -0
  34. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_engine.py +0 -0
  35. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_manager.py +0 -0
  36. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_builder.py +0 -0
  37. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_module_injection.py +0 -0
  38. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_registry.py +0 -0
  39. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_registry_types.py +0 -0
  40. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_readonly.py +0 -0
  41. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_repository.py +0 -0
  42. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_resource.py +0 -0
  43. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sqlalchemy_async.py +0 -0
  44. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/vector_search.py +0 -0
  45. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/__init__.py +0 -0
  46. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/async_enricher.py +0 -0
  47. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/composite.py +0 -0
  48. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/specs.py +0 -0
  49. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/field_map.py +0 -0
  50. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/__init__.py +0 -0
  51. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels.py +0 -0
  52. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
  53. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
  54. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels_string.py +0 -0
  55. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/backend_ops.py +0 -0
  56. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/expressions.py +0 -0
  57. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/handler.py +0 -0
  58. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/parquet_compiler.py +0 -0
  59. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/utils.py +0 -0
  60. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/value_parsing.py +0 -0
  61. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/__init__.py +0 -0
  62. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_backend_strategies.py +0 -0
  63. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_factory.py +0 -0
  64. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_gateway_init.py +0 -0
  65. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_payloads.py +0 -0
  66. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_series_filters.py +0 -0
  67. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/arrow_adapters.py +0 -0
  68. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/build_context.py +0 -0
  69. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/chunking.py +0 -0
  70. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/configured_load.py +0 -0
  71. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/core.py +0 -0
  72. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/core_describe.py +0 -0
  73. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/core_load.py +0 -0
  74. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/datacube_strategy.py +0 -0
  75. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/frame_convert.py +0 -0
  76. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/frame_strategies.py +0 -0
  77. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/incremental.py +0 -0
  78. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/load_execution.py +0 -0
  79. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/load_request.py +0 -0
  80. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/loaders.py +0 -0
  81. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/normalization.py +0 -0
  82. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/parquet_strategy.py +0 -0
  83. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/planning.py +0 -0
  84. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/policies.py +0 -0
  85. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/post_process.py +0 -0
  86. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/requests.py +0 -0
  87. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/return_type.py +0 -0
  88. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/select_cache.py +0 -0
  89. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/semi_join.py +0 -0
  90. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_describe.py +0 -0
  91. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_guard.py +0 -0
  92. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
  93. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_size_estimation.py +0 -0
  94. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_strategy.py +0 -0
  95. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
  96. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/helper.py +0 -0
  97. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/joins.py +0 -0
  98. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/__init__.py +0 -0
  99. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/config.py +0 -0
  100. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/discovery.py +0 -0
  101. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/file_info.py +0 -0
  102. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/filesystem.py +0 -0
  103. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/reader.py +0 -0
  104. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/reader_config.py +0 -0
  105. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/resource.py +0 -0
  106. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/__init__.py +0 -0
  107. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/base.py +0 -0
  108. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
  109. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/registry.py +0 -0
  110. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks.py +0 -0
  111. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks_text.py +0 -0
  112. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/py.typed +0 -0
  113. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/schema.py +0 -0
  114. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/watermark/__init__.py +0 -0
  115. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/watermark/incremental.py +0 -0
  116. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/watermark/store.py +0 -0
  117. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/SOURCES.txt +0 -0
  118. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/dependency_links.txt +0 -0
  119. {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/requires.txt +0 -0
  120. {boti_data-1.3.7 → boti_data-1.3.8}/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.7
3
+ Version: 1.3.8
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.7"
3
+ version = "1.3.8"
4
4
  description = "Data infrastructure for the Boti ecosystem"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from typing import Any, Protocol, runtime_checkable
12
12
 
13
+ import dask.dataframe as dd
13
14
  import pandas as pd
14
15
 
15
16
 
@@ -17,13 +18,15 @@ import pandas as pd
17
18
  class DataFrameTransformer(Protocol):
18
19
  """Generic DataFrame transform contract.
19
20
 
20
- Implementations receive a ``pd.DataFrame`` and arbitrary keyword
21
- arguments (used by higher layers to pass context) and must return
22
- a ``pd.DataFrame``.
21
+ Implementations receive a ``pd.DataFrame`` or ``dd.DataFrame`` and
22
+ arbitrary keyword arguments (used by higher layers to pass context)
23
+ and must return a frame of the same kind they were given.
23
24
 
24
25
  This protocol is intentionally minimal — ``**kwargs`` keeps it
25
26
  flexible so that etl-core can pass a ``TransformContext`` without
26
27
  boti-data knowing about it.
27
28
  """
28
29
 
29
- async def transform(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame: ...
30
+ async def transform(
31
+ self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
32
+ ) -> pd.DataFrame | dd.DataFrame: ...
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from typing import Any
11
11
 
12
+ import dask.dataframe as dd
12
13
  import pandas as pd
13
14
  from boti.core.security import has_dunder_identifier
14
15
 
@@ -46,16 +47,30 @@ class TypeCaster:
46
47
  ``"int64"``, ``"float64"``, ``"bool"``, ``"string"``, etc.
47
48
 
48
49
  Missing columns are silently skipped.
50
+
51
+ Dask frames
52
+ -----------
53
+ ``dd.DataFrame.astype()`` has no ``errors=`` kwarg, and a dask
54
+ column's dtype must be uniform across every partition — so there is
55
+ no dask equivalent of pandas' per-column "cast what you can, leave
56
+ the rest" behaviour. Against a ``dd.DataFrame``, a cast that would
57
+ have been silently skipped under ``errors="ignore"`` instead raises
58
+ immediately. Only list columns you're confident are castable when
59
+ the input is a dask frame.
49
60
  """
50
61
 
51
62
  def __init__(self, casts: dict[str, str]) -> None:
52
63
  self._casts = dict(casts)
53
64
 
54
- async def transform(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
65
+ async def transform(
66
+ self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
67
+ ) -> pd.DataFrame | dd.DataFrame:
55
68
  applicable = {col: dt for col, dt in self._casts.items() if col in df.columns}
56
- if applicable:
57
- df = df.astype(applicable, errors="ignore")
58
- return df
69
+ if not applicable:
70
+ return df
71
+ if isinstance(df, dd.DataFrame):
72
+ return df.astype(applicable)
73
+ return df.astype(applicable, errors="ignore")
59
74
 
60
75
 
61
76
  class RowFilter:
@@ -79,7 +94,9 @@ class RowFilter:
79
94
  def __init__(self, predicates: list[str]) -> None:
80
95
  self._predicates = list(predicates)
81
96
 
82
- async def transform(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
97
+ async def transform(
98
+ self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
99
+ ) -> pd.DataFrame | dd.DataFrame:
83
100
  for expr in self._predicates:
84
101
  _reject_dunder_expr(expr)
85
102
  mask = df.eval(expr) # nosec CWE-94 -- dunder-chain rejected above; see class docstring's Security section
@@ -113,7 +130,9 @@ class DerivedColumn:
113
130
  def __init__(self, columns: dict[str, str]) -> None:
114
131
  self._columns = dict(columns)
115
132
 
116
- async def transform(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
133
+ async def transform(
134
+ self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
135
+ ) -> pd.DataFrame | dd.DataFrame:
117
136
  for name, expr in self._columns.items():
118
137
  _reject_dunder_expr(expr)
119
138
  df[name] = df.eval(expr) # nosec CWE-94 -- dunder-chain rejected above; see class docstring's Security section
@@ -141,7 +160,9 @@ class Deduplicator:
141
160
  self._subset = subset
142
161
  self._keep = keep
143
162
 
144
- async def transform(self, df: pd.DataFrame, **kwargs: Any) -> pd.DataFrame:
163
+ async def transform(
164
+ self, df: pd.DataFrame | dd.DataFrame, **kwargs: Any
165
+ ) -> pd.DataFrame | dd.DataFrame:
145
166
  before = len(df)
146
167
  df = df.drop_duplicates(subset=self._subset, keep=self._keep).reset_index(
147
168
  drop=True,
@@ -73,47 +73,88 @@ def _string_field_names(resource: ParquetDataResource) -> set[str] | None:
73
73
  return string_fields or None
74
74
 
75
75
 
76
+ def _temporal_field_tzs(resource: ParquetDataResource) -> dict[str, str | None]:
77
+ """Returns {field_name: tz} for schema fields with a genuine PyArrow
78
+ timestamp type (``tz`` is ``None`` for a naive/no-tz column)."""
79
+ schema = dataset_schema(resource)
80
+ if schema is None:
81
+ return {}
82
+ return {field.name: field.type.tz for field in schema if pa.types.is_timestamp(field.type)}
83
+
84
+
76
85
  def coerce_temporal_filters(
77
86
  resource: ParquetDataResource, filters: dict[str, Any]
78
87
  ) -> dict[str, Any]:
79
- """Coerce date/datetime filter values to ISO strings for string-typed columns.
80
-
81
- A ``date``/``datetime`` value compared against a string column (e.g. an
82
- ISO-8601 date stored as text) has no PyArrow comparison kernel and would
83
- raise ``ArrowNotImplementedError`` on both the pushdown and residual
84
- paths. Rewriting the value to its ISO string makes the comparison a
85
- (correct) lexicographic string comparison. Columns with a genuine
86
- temporal type, and explicitly cast filters, are left untouched.
88
+ """Coerce filter values so their type matches the target column's PyArrow type.
89
+
90
+ Two mismatches share the same underlying failure mode — PyArrow's
91
+ filter-pushdown has no comparison kernel between mismatched temporal
92
+ representations, raising ``ArrowNotImplementedError``/``ArrowInvalid``
93
+ on both the pushdown and residual paths:
94
+
95
+ - A ``date``/``datetime`` value compared against a **string** column
96
+ (e.g. an ISO-8601 date stored as text) is rewritten to its ISO string,
97
+ making the comparison a correct lexicographic string comparison.
98
+ - A bare ``datetime.date`` value compared against a genuine **timestamp**
99
+ column (tz-aware or not) is promoted to a ``pd.Timestamp`` carrying
100
+ the column's own tz, since callers commonly build period filters via
101
+ ``pd.to_datetime(x).date()`` regardless of the target column's actual
102
+ precision (see ``prepare_period_filters()`` in ``gateway/normalization.py``).
103
+
104
+ Explicitly cast filters (``field__date__gte=...``) and values that
105
+ already carry full datetime precision are left untouched either way.
87
106
  """
88
107
  if not filters:
89
108
  return filters
90
- string_fields = _string_field_names(resource)
91
- if not string_fields:
109
+ string_fields = _string_field_names(resource) or set()
110
+ temporal_field_tzs = _temporal_field_tzs(resource)
111
+ if not string_fields and not temporal_field_tzs:
92
112
  return filters
93
- return coerce_mapping(filters, string_fields)
113
+ return coerce_mapping(filters, string_fields, temporal_field_tzs)
94
114
 
95
115
 
96
- def coerce_mapping(filters: dict[str, Any], string_fields: set[str]) -> dict[str, Any]:
116
+ def coerce_mapping(
117
+ filters: dict[str, Any],
118
+ string_fields: set[str],
119
+ temporal_field_tzs: dict[str, str | None] | None = None,
120
+ ) -> dict[str, Any]:
121
+ temporal_field_tzs = temporal_field_tzs or {}
97
122
  coerced: dict[str, Any] = {}
98
123
  for key, value in filters.items():
99
124
  if key in {"$and", "$or"} and isinstance(value, (list, tuple)):
100
125
  coerced[key] = [
101
- coerce_mapping(sub, string_fields) if isinstance(sub, dict) else sub
126
+ coerce_mapping(sub, string_fields, temporal_field_tzs)
127
+ if isinstance(sub, dict)
128
+ else sub
102
129
  for sub in value
103
130
  ]
104
131
  elif key == "$not" and isinstance(value, dict):
105
- coerced[key] = coerce_mapping(value, string_fields)
132
+ coerced[key] = coerce_mapping(value, string_fields, temporal_field_tzs)
106
133
  elif str(key).startswith("$"):
107
134
  coerced[key] = value
108
135
  else:
109
- field, casting, _op = parse_filter_key(key)
110
- if casting is None and field in string_fields:
111
- coerced[key] = stringify_temporal(value)
112
- else:
113
- coerced[key] = value
136
+ coerced[key] = _coerce_single_filter_value(
137
+ key, value, string_fields, temporal_field_tzs
138
+ )
114
139
  return coerced
115
140
 
116
141
 
142
+ def _coerce_single_filter_value(
143
+ key: str,
144
+ value: Any,
145
+ string_fields: set[str],
146
+ temporal_field_tzs: dict[str, str | None],
147
+ ) -> Any:
148
+ field, casting, _op = parse_filter_key(key)
149
+ if casting is not None:
150
+ return value
151
+ if field in string_fields:
152
+ return stringify_temporal(value)
153
+ if field in temporal_field_tzs:
154
+ return promote_bare_date_to_timestamp(value, temporal_field_tzs[field])
155
+ return value
156
+
157
+
117
158
  def stringify_temporal(value: Any) -> Any:
118
159
  def convert(item: Any) -> Any:
119
160
  # pandas.Timestamp subclasses datetime, so it is handled here too.
@@ -128,6 +169,27 @@ def stringify_temporal(value: Any) -> Any:
128
169
  return convert(value)
129
170
 
130
171
 
172
+ def promote_bare_date_to_timestamp(value: Any, tz: str | None) -> Any:
173
+ """Promote a bare ``datetime.date`` to a ``pd.Timestamp`` in ``tz``.
174
+
175
+ Values already carrying datetime precision (``pd.Timestamp``/``datetime.datetime``,
176
+ which subclasses ``date``) are left untouched — only a truly bare
177
+ ``date`` needs promotion to satisfy PyArrow's timestamp comparison kernel.
178
+ """
179
+
180
+ def convert(item: Any) -> Any:
181
+ if isinstance(item, dt.datetime):
182
+ return item
183
+ if isinstance(item, dt.date):
184
+ ts = pd.Timestamp(item)
185
+ return ts.tz_localize(tz) if tz else ts
186
+ return item
187
+
188
+ if isinstance(value, (list, tuple)):
189
+ return type(value)(convert(item) for item in value)
190
+ return convert(value)
191
+
192
+
131
193
  def raw_filters_to_expression(filters: list[Any] | None) -> ds.Expression | None:
132
194
  if not filters:
133
195
  return None
@@ -108,13 +108,73 @@ def _validate_meta_matches_real_dtypes(frame: dd.DataFrame, *, sink_name: str) -
108
108
  )
109
109
 
110
110
 
111
+ def _arrow_convertible(sample: pd.DataFrame) -> bool:
112
+ try:
113
+ pa.Table.from_pandas(sample, preserve_index=False)
114
+ except (pa.ArrowInvalid, pa.ArrowTypeError):
115
+ return False
116
+ return True
117
+
118
+
119
+ def _validate_meta_object_columns_are_arrow_convertible(
120
+ frame: dd.DataFrame, *, sink_name: str
121
+ ) -> None:
122
+ """Guard against dask's meta_nonempty() sentinel-object corruption (wishlist #10).
123
+
124
+ Distinct from `_validate_meta_matches_real_dtypes()` above: that check
125
+ catches meta/real *dtype* divergence, but a genuine ``object``-dtype
126
+ column (e.g. a nested/struct parquet column, or one column of two
127
+ otherwise-identical branches merged via `HybridDataset` where the other
128
+ is entirely null in this slice) can have meta and real dtype agree
129
+ (both ``object``) while still breaking PyArrow at write time. On
130
+ pandas>=3.0, dask's `_nonempty_series()` has no per-partition data to
131
+ sample from `frame._meta` (always 0 rows) and falls back to a bare
132
+ `object()` placeholder for any generic object dtype -- a value with no
133
+ representable Arrow type. Left unchecked, that surfaces as an opaque
134
+ `pyarrow.lib.ArrowInvalid: Could not convert <object object at 0x...>`
135
+ at write time with nothing pointing back to the real column. Catch it
136
+ against the synthesized sample before any sink commits to a write.
137
+ """
138
+ object_columns = [
139
+ column for column, dtype in frame.dtypes.items() if pd.api.types.is_object_dtype(dtype)
140
+ ]
141
+ if not object_columns:
142
+ return
143
+ sample = frame._meta_nonempty
144
+ bad_columns = [column for column in object_columns if not _arrow_convertible(sample[[column]])]
145
+ if not bad_columns:
146
+ return
147
+ raise ValueError(
148
+ f"{sink_name} detected object-dtype column(s) with no PyArrow-representable "
149
+ f"value in dask's synthesized meta sample: {bad_columns!r}. This typically "
150
+ "happens with a nested/struct-typed column, or one entirely null in one branch "
151
+ "of a concat/merge (e.g. HybridDataset's historical vs live branches) while a "
152
+ "sibling branch has real values -- dask can't derive a placeholder PyArrow can "
153
+ "place. Project the load down to only the columns actually needed, or cast the "
154
+ "column to an explicit scalar dtype before writing."
155
+ )
156
+
157
+
111
158
  def prepare_partitioned_frame(
112
159
  frame: dd.DataFrame,
113
160
  *,
114
161
  partition_on: Sequence[str] | None,
115
162
  date_field: str | None,
116
163
  sink_name: str,
164
+ validate_arrow_convertible: bool = False,
117
165
  ) -> dd.DataFrame:
166
+ """Derive partition columns and validate the frame's dask meta before a write.
167
+
168
+ ``validate_arrow_convertible`` gates
169
+ `_validate_meta_object_columns_are_arrow_convertible()`: that guard
170
+ exists for PyArrow's schema-inference step, which only ``ParquetSink``
171
+ goes through -- ``CsvSink``/``JsonlSink`` write each partition's real
172
+ data straight through pandas' own ``to_csv``/``to_json`` and would
173
+ reject a perfectly writable object column for a failure mode they never
174
+ hit. `_validate_meta_matches_real_dtypes()` above stays unconditional:
175
+ a meta/real dtype mismatch is a signal of upstream corruption regardless
176
+ of sink type, not a PyArrow-specific concern.
177
+ """
118
178
  if partition_on:
119
179
  partition_columns = list(partition_on)
120
180
  missing = [name for name in partition_columns if name not in frame.columns]
@@ -129,6 +189,8 @@ def prepare_partitioned_frame(
129
189
  )
130
190
 
131
191
  _validate_meta_matches_real_dtypes(frame, sink_name=sink_name)
192
+ if validate_arrow_convertible:
193
+ _validate_meta_object_columns_are_arrow_convertible(frame, sink_name=sink_name)
132
194
  return frame
133
195
 
134
196
 
@@ -96,6 +96,7 @@ class ParquetSink(_AsyncWriteViaThreadMixin, PicklableLifecycleCoreMixin, Lifecy
96
96
  partition_on=self.partition_on,
97
97
  date_field=date_field,
98
98
  sink_name="ParquetSink",
99
+ validate_arrow_convertible=True,
99
100
  )
100
101
  if persist:
101
102
  ddf = safe_persist(ddf)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boti-data
3
- Version: 1.3.7
3
+ Version: 1.3.8
4
4
  Summary: Data infrastructure for the Boti ecosystem
5
5
  Author: Luis Valverde
6
6
  Maintainer-email: Luis Valverde <lvalverdeb@gmail.com>
File without changes
File without changes