xarray_sql 0.3.0__cp310-abi3-win_amd64.whl

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.
xarray_sql/reader.py ADDED
@@ -0,0 +1,332 @@
1
+ """Lazy Arrow stream reader for xarray Datasets.
2
+
3
+ This module provides XarrayRecordBatchReader, which implements the Arrow
4
+ PyCapsule Interface (__arrow_c_stream__) to enable zero-copy, lazy streaming
5
+ of xarray data to DataFusion and other Arrow consumers.
6
+
7
+ The implementation delegates to PyArrow's RecordBatchReader for the
8
+ actual stream implementation, wrapping xarray block iteration in a generator.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterator
14
+ from typing import TYPE_CHECKING
15
+
16
+ import numpy as np
17
+ import pyarrow as pa
18
+ import xarray as xr
19
+
20
+ from .df import (
21
+ Block,
22
+ Chunks,
23
+ DEFAULT_BATCH_SIZE,
24
+ _block_metadata,
25
+ _block_slices_from_resolved,
26
+ _parse_schema,
27
+ block_slices,
28
+ iter_record_batches,
29
+ resolve_chunks,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from ._native import LazyArrowStreamTable
34
+
35
+
36
+ class XarrayRecordBatchReader:
37
+ """A lazy Arrow stream reader for xarray Datasets.
38
+
39
+ Implements the Arrow PyCapsule Interface (__arrow_c_stream__) to enable
40
+ zero-copy, lazy streaming of xarray data to DataFusion and other Arrow
41
+ consumers.
42
+
43
+ The key property is that xarray blocks are only converted to Arrow
44
+ RecordBatches when the consumer calls get_next (e.g., during DataFusion's
45
+ collect()), NOT when the reader is created or registered.
46
+
47
+ Attributes:
48
+ schema: The Arrow schema for the stream.
49
+
50
+ Example:
51
+ >>> import xarray as xr
52
+ >>> from xarray_sql import XarrayRecordBatchReader
53
+ >>> ds = xr.tutorial.open_dataset('air_temperature')
54
+ >>> reader = XarrayRecordBatchReader(ds, chunks={'time': 240})
55
+ >>> # At this point, NO data has been read from xarray
56
+ >>> # Data is only read when consumed:
57
+ >>> import pyarrow as pa
58
+ >>> pa_reader = pa.RecordBatchReader.from_stream(reader)
59
+ >>> for batch in pa_reader:
60
+ ... print(batch.num_rows) # Data read here
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ ds: xr.Dataset,
66
+ chunks: Chunks = None,
67
+ *,
68
+ batch_size: int = DEFAULT_BATCH_SIZE,
69
+ _iteration_callback: (
70
+ Callable[[Block, list[str] | None], None] | None
71
+ ) = None,
72
+ ):
73
+ """Initialize the lazy reader.
74
+
75
+ Args:
76
+ ds: An xarray Dataset. All data_vars must share the same dimensions.
77
+ chunks: Xarray-like chunks specification. If not provided, uses
78
+ the Dataset's existing chunks.
79
+ batch_size: Maximum rows per emitted Arrow RecordBatch. Smaller
80
+ values let DataFusion start processing earlier at the cost of
81
+ more Python→Arrow conversion calls.
82
+ _iteration_callback: Internal callback for testing. Called with
83
+ each block dict just before it's converted to Arrow. This
84
+ allows tests to track when iteration actually occurs.
85
+ """
86
+ self._ds = ds
87
+ self._chunks = chunks
88
+ self._batch_size = batch_size
89
+ self._schema = _parse_schema(ds)
90
+ self._iteration_callback = _iteration_callback
91
+ self._consumed = False
92
+
93
+ # Validate dimensions
94
+ fst = next(iter(ds.values())).dims
95
+ if not all(da.dims == fst for da in ds.values()):
96
+ raise ValueError(
97
+ "All dimensions must be equal. Please filter data_vars in the Dataset."
98
+ )
99
+
100
+ @property
101
+ def schema(self) -> pa.Schema:
102
+ """The Arrow schema for this stream."""
103
+ return self._schema
104
+
105
+ def _generate_batches(self) -> Iterator[pa.RecordBatch]:
106
+ """Generate RecordBatches lazily from xarray blocks.
107
+
108
+ This generator is only consumed when the Arrow stream's get_next
109
+ is called, ensuring true lazy evaluation. Each xarray block is
110
+ emitted as one or more RecordBatches of at most self._batch_size rows.
111
+ """
112
+ for block in block_slices(self._ds, self._chunks):
113
+ # Call the iteration callback if provided (for testing).
114
+ # XarrayRecordBatchReader has no projection concept, so always passes None.
115
+ if self._iteration_callback is not None:
116
+ self._iteration_callback(block, None)
117
+
118
+ yield from iter_record_batches(
119
+ self._ds.isel(block), self._schema, self._batch_size
120
+ )
121
+
122
+ def __arrow_c_stream__(
123
+ self, requested_schema: object | None = None
124
+ ) -> object:
125
+ """Export as Arrow C Stream via PyCapsule.
126
+
127
+ This method is called by Arrow consumers (like DataFusion) to get
128
+ a C-level stream interface. The actual data iteration only begins
129
+ when the consumer calls get_next on the stream.
130
+
131
+ Args:
132
+ requested_schema: Optional schema for type casting. Currently
133
+ passed through to PyArrow's implementation.
134
+
135
+ Returns:
136
+ PyCapsule containing ArrowArrayStream pointer with name
137
+ "arrow_array_stream".
138
+
139
+ Raises:
140
+ RuntimeError: If the stream has already been consumed.
141
+ """
142
+ if self._consumed:
143
+ raise RuntimeError(
144
+ "Stream already consumed. XarrayRecordBatchReader can only "
145
+ "be iterated once. Create a new reader for additional iterations."
146
+ )
147
+ self._consumed = True
148
+
149
+ # Create a PyArrow RecordBatchReader from our generator
150
+ # The generator is NOT consumed here - only when get_next is called
151
+ reader = pa.RecordBatchReader.from_batches(
152
+ self._schema, self._generate_batches()
153
+ )
154
+
155
+ # Delegate to PyArrow's __arrow_c_stream__ implementation
156
+ return reader.__arrow_c_stream__(requested_schema)
157
+
158
+ def __arrow_c_schema__(
159
+ self, requested_schema: object | None = None
160
+ ) -> object:
161
+ """Export the schema as Arrow C Schema via PyCapsule.
162
+
163
+ This allows consumers to inspect the schema without consuming the stream.
164
+
165
+ Args:
166
+ requested_schema: Optional schema for negotiation (unused).
167
+
168
+ Returns:
169
+ PyCapsule containing ArrowSchema pointer.
170
+ """
171
+ return self._schema.__arrow_c_schema__()
172
+
173
+
174
+ def read_xarray(ds: xr.Dataset, chunks: Chunks = None) -> pa.RecordBatchReader:
175
+ """Pivots an Xarray Dataset into a PyArrow Table, partitioned by chunks.
176
+
177
+ Args:
178
+ ds: An Xarray Dataset. All `data_vars` must share the same dimensions.
179
+ chunks: Xarray-like chunks. If not provided, will default to the
180
+ Dataset's chunks. The product of the chunk sizes becomes the
181
+ standard length of each dataframe partition.
182
+
183
+ Returns:
184
+ A PyArrow RecordBatchReader, which is a table representation of the input
185
+ Dataset.
186
+ """
187
+ reader = XarrayRecordBatchReader(ds, chunks=chunks)
188
+ return pa.RecordBatchReader.from_stream(reader)
189
+
190
+
191
+ def read_xarray_table(
192
+ ds: xr.Dataset,
193
+ chunks: Chunks = None,
194
+ *,
195
+ batch_size: int = DEFAULT_BATCH_SIZE,
196
+ coord_arrays: dict[str, np.ndarray] | None = None,
197
+ _iteration_callback: (
198
+ Callable[[Block, list[str] | None], None] | None
199
+ ) = None,
200
+ ) -> "LazyArrowStreamTable":
201
+ """Create a lazy DataFusion table from an xarray Dataset.
202
+
203
+ This is the simplest way to register xarray data with DataFusion.
204
+ Data is only read when queries are executed, not during registration.
205
+ The table can be queried multiple times.
206
+
207
+ Each chunk becomes a separate partition, enabling DataFusion's parallel
208
+ execution across multiple cores.
209
+
210
+ Note:
211
+ SQL queries with WHERE clauses on dimension columns (time, lat, lon, etc.)
212
+ automatically prune partitions that can't contain matching rows — this is
213
+ called *filter pushdown*. For example:
214
+
215
+ # This query will skip loading partitions with time < '2020-02-01'
216
+ result = ctx.sql('SELECT * FROM air WHERE time > "2020-02-01"').collect()
217
+
218
+ Supported operators: `=`, `<`, `>`, `<=`, `>=`, `BETWEEN`, `IN`, `AND`, `OR`.
219
+
220
+ Args:
221
+ ds: An xarray Dataset. All data_vars must share the same dimensions.
222
+ chunks: Xarray-like chunks specification. If not provided, uses
223
+ the Dataset's existing chunks.
224
+ batch_size: Maximum rows per Arrow RecordBatch emitted per partition.
225
+ Smaller values let DataFusion start processing earlier; the default
226
+ (65 536) works well for most datasets.
227
+ coord_arrays: Pre-materialised coordinate arrays keyed by dim-name
228
+ string. Hand in to share a single read across multiple tables
229
+ built from the same parent Dataset (e.g. surface + atmosphere
230
+ from ARCO-ERA5); the dim coords are otherwise read once per
231
+ ``read_xarray_table`` call, which is a network round-trip for
232
+ Zarr-backed datasets.
233
+ _iteration_callback: Internal callback for testing. Called with
234
+ each block dict just before it's converted to Arrow.
235
+
236
+ Returns:
237
+ A LazyArrowStreamTable ready for registration with DataFusion.
238
+
239
+ Example:
240
+ >>> from datafusion import SessionContext
241
+ >>> import xarray as xr
242
+ >>> from xarray_sql import read_xarray_table
243
+ >>>
244
+ >>> ds = xr.tutorial.open_dataset('air_temperature')
245
+ >>> table = read_xarray_table(ds, chunks={'time': 240})
246
+ >>>
247
+ >>> ctx = SessionContext()
248
+ >>> ctx.register_table('air', table)
249
+ >>>
250
+ >>> # Data is only read here, during query execution
251
+ >>> # Filters on 'time' will prune partitions automatically!
252
+ >>> result = ctx.sql('SELECT AVG(air) FROM air').collect()
253
+ """
254
+ from ._native import LazyArrowStreamTable
255
+
256
+ schema = _parse_schema(ds)
257
+
258
+ # Hoist coordinate reads once; avoids N_partitions remote I/O calls for
259
+ # Zarr-backed datasets (e.g. ARCO-ERA5 on GCS). When the caller supplies
260
+ # pre-materialised arrays (e.g. shared across surface + atmosphere
261
+ # tables), reuse them and skip the extra read.
262
+ if coord_arrays is None:
263
+ coord_arrays = {str(dim): ds.coords[dim].values for dim in ds.dims}
264
+
265
+ # Determine which column names are data variables (not dimension coordinates).
266
+ # Used by the factory to skip loading unrequested variables.
267
+ data_var_names = set(ds.data_vars.keys())
268
+
269
+ def make_partition_factory(
270
+ block: Block,
271
+ ) -> Callable[[list[str] | None], pa.RecordBatchReader]:
272
+ def make_stream(
273
+ projection_names: list[str] | None,
274
+ ) -> pa.RecordBatchReader:
275
+ if _iteration_callback is not None:
276
+ _iteration_callback(block, projection_names)
277
+
278
+ if projection_names is not None:
279
+ # Restrict to the data variables mentioned in the projection.
280
+ # Dimension coordinates come along automatically via coords.
281
+ data_vars_needed = [
282
+ c for c in projection_names if c in data_var_names
283
+ ]
284
+ if data_vars_needed:
285
+ ds_block = ds[data_vars_needed].isel(block)
286
+ else:
287
+ # Only dimension coords requested — drop all data vars to avoid
288
+ # loading them unnecessarily (e.g. for queries like SELECT lat, lon).
289
+ ds_block = ds.drop_vars(list(ds.data_vars)).isel(block)
290
+ batch_schema = pa.schema(
291
+ [schema.field(name) for name in projection_names]
292
+ )
293
+ else:
294
+ ds_block = ds.isel(block)
295
+ batch_schema = schema
296
+
297
+ return pa.RecordBatchReader.from_batches(
298
+ batch_schema,
299
+ iter_record_batches(ds_block, batch_schema, batch_size),
300
+ )
301
+
302
+ return make_stream
303
+
304
+ # Separate dims whose chunk bounds vary across partitions from those
305
+ # whose bounds are constant (one chunk spanning the whole axis). For the
306
+ # latter we compute min/max once instead of re-scanning the full coord
307
+ # array on every partition — dominant cost when registering hundreds of
308
+ # thousands of single-time-step partitions on a 4-D dataset like ERA5.
309
+ resolved = resolve_chunks(ds, chunks)
310
+ varying_dims = [d for d, tup in resolved.items() if len(tup) > 1]
311
+ static_dims = [d for d in ds.dims if d not in varying_dims]
312
+ static_block: Block = {d: slice(None) for d in static_dims}
313
+ static_ranges = _block_metadata(
314
+ coord_arrays, static_block, dims=static_dims
315
+ )
316
+
317
+ def partition_pairs():
318
+ """Lazily yield (factory, metadata) for each partition.
319
+
320
+ Consuming this generator one item at a time means Python never holds
321
+ all N block dicts, metadata dicts, and factory closures simultaneously.
322
+ Peak Python memory during registration is O(1) per partition instead
323
+ of O(N_partitions).
324
+ """
325
+ for block in _block_slices_from_resolved(ds, resolved):
326
+ dynamic = _block_metadata(coord_arrays, block, dims=varying_dims)
327
+ yield (
328
+ make_partition_factory(block),
329
+ {**static_ranges, **dynamic},
330
+ )
331
+
332
+ return LazyArrowStreamTable(partition_pairs(), schema)
xarray_sql/sql.py ADDED
@@ -0,0 +1,191 @@
1
+ import xarray as xr
2
+ from datafusion import SessionContext
3
+ from datafusion.catalog import Schema
4
+ from collections import defaultdict
5
+
6
+ from . import cftime as cft
7
+ from .df import Chunks
8
+ from .ds import XarrayDataFrame
9
+ from .reader import read_xarray_table
10
+
11
+
12
+ class XarrayContext(SessionContext):
13
+ """A datafusion `SessionContext` that also supports `xarray.Dataset`s."""
14
+
15
+ def __init__(self, *args, **kwargs):
16
+ super().__init__(*args, **kwargs)
17
+ # Track registered xarray Datasets so XarrayDataFrame can recover
18
+ # defaults (dimension_columns) and metadata (var/dataset attrs,
19
+ # non-dim coords, dim-coord dtype) that the forward pivot drops.
20
+ # Keys are the fully-qualified table names users will reference
21
+ # in SQL (e.g. ``"air"`` for a uniform-dim Dataset, or
22
+ # ``"era5.surface"`` for one entry from a multi-dim-group split).
23
+ self._registered_datasets: dict[str, xr.Dataset] = {}
24
+
25
+ def from_dataset(
26
+ self,
27
+ name: str,
28
+ input_table: xr.Dataset,
29
+ *,
30
+ table_names: dict[tuple[str, ...], str] | None = None,
31
+ chunks: Chunks = None,
32
+ ):
33
+ """Register an xarray Dataset as one or more queryable SQL tables.
34
+
35
+ When all data variables share the same dimensions, the dataset is
36
+ registered as a single table named ``name``. When variables have
37
+ differing dimensions (e.g. some on a 3D grid and others on a 4D
38
+ grid), the dataset is split into one table per dimension group.
39
+ The tables are registered under a SQL schema (namespace) named
40
+ ``name`` and named ``<dim1>_<dim2>_...`` by default::
41
+
42
+ ctx.from_dataset('era5', ds, chunks={'time': 24})
43
+ # registers tables: 'era5.time_lat_lon' and
44
+ # 'era5.time_lat_lon_level'
45
+ ctx.sql('SELECT AVG(temperature_2m) FROM era5.time_lat_lon')
46
+
47
+ Use ``table_names`` to override the name for specific dimension
48
+ tuples::
49
+
50
+ ctx.from_dataset(
51
+ 'era5', ds,
52
+ table_names={('time', 'lat', 'lon'): 'surface'},
53
+ )
54
+ ctx.sql('SELECT * FROM era5.surface')
55
+
56
+ For datasets with non-Gregorian cftime coordinates (e.g. 360_day,
57
+ julian), a ``cftime()`` scalar UDF is automatically registered so
58
+ you can write ergonomic SQL filters::
59
+
60
+ ctx.from_dataset("ds360", ds, chunks={"time": 6})
61
+ ctx.sql("SELECT * FROM ds360 WHERE time >= cftime('2000-07-01')")
62
+
63
+ .. note::
64
+
65
+ Only one ``cftime()`` UDF is registered per context, using the
66
+ units and calendar of the *first* non-Gregorian coordinate
67
+ encountered. If you register multiple datasets with *different*
68
+ non-Gregorian calendars (e.g. one 360_day and one julian), the
69
+ UDF from the first registration will be used for all subsequent
70
+ ``cftime()`` calls and may produce incorrect offsets for the
71
+ other dataset. In that case, create a separate ``XarrayContext``
72
+ for each calendar.
73
+
74
+ Args:
75
+ name: The SQL identifier under which the dataset is registered.
76
+ For datasets with uniform dimensions, this is the table
77
+ name. For datasets with mixed dimensions, this is the name
78
+ of a SQL schema (namespace) containing one table per
79
+ dimension group.
80
+ input_table: An xarray Dataset.
81
+ table_names: Optional mapping from dimension tuples to custom
82
+ table names within the schema, used when the dataset has
83
+ variables with differing dimensions.
84
+ chunks: Xarray-like chunks specification. If not provided, uses
85
+ the Dataset's existing chunks.
86
+
87
+ Returns:
88
+ self, to allow chaining.
89
+ """
90
+ groups = _group_vars_by_dims(input_table)
91
+
92
+ # Materialise dim coordinates once and share across every sub-table.
93
+ # For Zarr-backed parents (e.g. ARCO-ERA5 on GCS) this saves one
94
+ # network round-trip per dim per dim-group.
95
+ coord_arrays = {
96
+ str(dim): input_table.coords[dim].values for dim in input_table.dims
97
+ }
98
+
99
+ if len(groups) <= 1:
100
+ self._registered_datasets[name] = input_table
101
+ return self._from_dataset(
102
+ name, input_table, chunks, coord_arrays=coord_arrays
103
+ )
104
+
105
+ table_names = table_names or {}
106
+ schema = Schema.memory_schema(self)
107
+ self.catalog().register_schema(name, schema)
108
+
109
+ for dims, var_names in groups.items():
110
+ # Scalar variables group under empty dims, where "_".join(()) is
111
+ # the empty string; fall back to a valid default table name.
112
+ sub_name = table_names.get(dims, "_".join(dims) or "scalar")
113
+ sub_ds = input_table[var_names]
114
+ self._from_dataset(
115
+ sub_name,
116
+ sub_ds,
117
+ chunks,
118
+ schema=schema,
119
+ coord_arrays=coord_arrays,
120
+ )
121
+ # Track the fully-qualified name so XarrayDataFrame metadata
122
+ # recovery can find this Dataset on round-trip.
123
+ self._registered_datasets[f"{name}.{sub_name}"] = sub_ds
124
+
125
+ return self
126
+
127
+ def _from_dataset(
128
+ self,
129
+ table_name: str,
130
+ input_table: xr.Dataset,
131
+ chunks: Chunks = None,
132
+ schema: Schema | None = None,
133
+ coord_arrays: dict | None = None,
134
+ ):
135
+ """Register a Dataset as a single SQL table.
136
+
137
+ Registers a top-level table by default, or a table inside ``schema``
138
+ (a SQL namespace) when one is given.
139
+ """
140
+ register = (
141
+ self.register_table if schema is None else schema.register_table
142
+ )
143
+ register(
144
+ table_name,
145
+ read_xarray_table(input_table, chunks, coord_arrays=coord_arrays),
146
+ )
147
+ self._maybe_register_cftime_udf(input_table)
148
+ return self
149
+
150
+ def _maybe_register_cftime_udf(self, ds: xr.Dataset) -> None:
151
+ """Auto-register a cftime() UDF for non-Gregorian cftime coordinates."""
152
+ for coord_name in ds.dims:
153
+ if cft.is_cftime_index(ds, coord_name):
154
+ units, cal = cft.encoding(ds, coord_name)
155
+ if not cft.is_gregorian_like(cal):
156
+ self.register_udf(cft.make_cftime_udf(units, cal))
157
+ break # One UDF per context is enough.
158
+
159
+ def sql(self, query: str, *args, **kwargs) -> XarrayDataFrame:
160
+ """Run a SQL query, returning an :class:`XarrayDataFrame` wrapper.
161
+
162
+ Identical to ``datafusion.SessionContext.sql`` except the returned
163
+ object wraps the DataFusion DataFrame. The wrapper exposes
164
+ ``.to_pandas()`` (unchanged), forwards every other DataFusion
165
+ method via ``__getattr__``, and adds
166
+ ``.to_dataset(dimension_columns=[...])`` for round-tripping the
167
+ result back to an ``xr.Dataset``.
168
+
169
+ Args:
170
+ query: A SQL query string.
171
+ *args: Forwarded to ``SessionContext.sql``.
172
+ **kwargs: Forwarded to ``SessionContext.sql``.
173
+
174
+ Returns:
175
+ An :class:`XarrayDataFrame` wrapping the DataFusion DataFrame.
176
+ """
177
+ inner = super().sql(query, *args, **kwargs)
178
+ return XarrayDataFrame(inner, templates=self._registered_datasets)
179
+
180
+
181
+ def _group_vars_by_dims(ds: xr.Dataset) -> dict[tuple[str, ...], list[str]]:
182
+ """Group variables in the dataset based on shared dims.
183
+
184
+ ("time", "lat", "lon"): ["temperature_2m", "wind_speed"],
185
+ ("time", "lat", "lon", "level"): ["pressure", "humidity"]
186
+ """
187
+ groups = defaultdict(list)
188
+ for var_name, var in ds.data_vars.items():
189
+ dims = var.dims
190
+ groups[dims].append(var_name)
191
+ return groups