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.
- {boti_data-1.3.7 → boti_data-1.3.8}/PKG-INFO +1 -1
- {boti_data-1.3.7 → boti_data-1.3.8}/pyproject.toml +1 -1
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/protocol.py +7 -4
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/transformers.py +28 -7
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/schema_filters.py +81 -19
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks_common.py +62 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks_parquet.py +1 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/PKG-INFO +1 -1
- {boti_data-1.3.7 → boti_data-1.3.8}/README.md +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/setup.cfg +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/arrow_schema.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/connection_catalog.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/artifact.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/base.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/contract.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/datacube/resource.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/dataset/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/dataset/hybrid.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/dataset/hybrid_support.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/arrow_schema_mapper.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/engine_registry.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_execution.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_loader.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_planner.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_planner_async.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_planning_math.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_row_materialization.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_statements.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/partitioned_types.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_config.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_engine.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_manager.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_builder.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_module_injection.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_registry.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_model_registry_types.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_readonly.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_repository.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sql_resource.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/sqlalchemy_async.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/db/vector_search.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/async_enricher.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/composite.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/enrichment/specs.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/field_map.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels_comparison.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels_compile.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/arrow_kernels_string.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/backend_ops.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/expressions.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/handler.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/parquet_compiler.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/utils.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/filters/value_parsing.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_backend_strategies.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_factory.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_gateway_init.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_payloads.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/_series_filters.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/arrow_adapters.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/build_context.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/chunking.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/configured_load.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/core.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/core_describe.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/core_load.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/datacube_strategy.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/frame_convert.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/frame_strategies.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/incremental.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/load_execution.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/load_request.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/loaders.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/normalization.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/parquet_strategy.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/planning.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/policies.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/post_process.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/requests.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/return_type.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/select_cache.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/semi_join.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_describe.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_guard.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_partitioned_exec.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_size_estimation.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_strategy.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/gateway/sql_strategy_configured_async.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/helper.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/joins.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/config.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/discovery.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/file_info.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/filesystem.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/reader.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/reader_config.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/parquet/resource.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/base.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/parquet_pipeline.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/registry.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/pipelines/sinks_text.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/py.typed +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/schema.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/watermark/__init__.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/watermark/incremental.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data/watermark/store.py +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/SOURCES.txt +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/dependency_links.txt +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/requires.txt +0 -0
- {boti_data-1.3.7 → boti_data-1.3.8}/src/boti_data.egg-info/top_level.txt +0 -0
|
@@ -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``
|
|
21
|
-
arguments (used by higher layers to pass context)
|
|
22
|
-
a
|
|
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(
|
|
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(
|
|
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
|
-
|
|
58
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|