Skip to content

Commit b365cb9

Browse files
committed
Merge remote-tracking branch 'upstream/v1.5-variegata' into main_prep
2 parents ab63b5f + b936678 commit b365cb9

22 files changed

Lines changed: 1028 additions & 286 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Supported: `3.10`, `3.11`, `3.12`, `3.13`, `3.14`. Do **not** use free-threaded
121121

122122
Key `pyproject.toml` settings:
123123

124-
- `BUILD_EXTENSIONS = "core_functions;json;parquet;icu;jemalloc"` — extensions built into the wheel.
124+
- `BUILD_EXTENSIONS = "core_functions;json;parquet;icu"` — extensions built into the wheel. (jemalloc is part of DuckDB core and is auto-enabled on supported platforms — 64-bit Linux, non-musl, non-BSD.)
125125
- Editable overrides: `build-dir = "build/debug/"`, `editable.rebuild = true`, `editable.mode = "redirect"`, `cmake.build-type = "Debug"`, `DISABLE_UNITY = "1"` (unity disabled for better debugging).
126126
- Coverage overrides: `build-dir = "build/coverage/"`, `RelWithDebInfo`, `--coverage` flags. Activate with `COVERAGE=true uv sync ...`.
127127

_duckdb-stubs/__init__.pyi

Lines changed: 28 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -309,22 +309,15 @@ class DuckDBPyConnection:
309309
strict_mode: bool | None = None,
310310
) -> DuckDBPyRelation: ...
311311
def from_df(self, df: pandas.DataFrame) -> DuckDBPyRelation: ...
312-
@typing.overload
313-
def from_parquet(
314-
self,
315-
file_glob: str,
316-
binary_as_string: bool = False,
317-
*,
318-
file_row_number: bool = False,
319-
filename: bool = False,
320-
hive_partitioning: bool = False,
321-
union_by_name: bool = False,
322-
compression: ParquetCompression | None = None,
323-
) -> DuckDBPyRelation: ...
324-
@typing.overload
325312
def from_parquet(
326313
self,
327-
file_globs: Sequence[str],
314+
path_or_buffer: str
315+
| bytes
316+
| os.PathLike[str]
317+
| os.PathLike[bytes]
318+
| typing.IO[bytes]
319+
| typing.IO[str]
320+
| Sequence[str | bytes | os.PathLike[str] | os.PathLike[bytes] | typing.IO[bytes] | typing.IO[str]],
328321
binary_as_string: bool = False,
329322
*,
330323
file_row_number: bool = False,
@@ -433,22 +426,15 @@ class DuckDBPyConnection:
433426
hive_types: HiveTypes | None = None,
434427
hive_types_autocast: bool | None = None,
435428
) -> DuckDBPyRelation: ...
436-
@typing.overload
437-
def read_parquet(
438-
self,
439-
file_glob: str,
440-
binary_as_string: bool = False,
441-
*,
442-
file_row_number: bool = False,
443-
filename: bool = False,
444-
hive_partitioning: bool = False,
445-
union_by_name: bool = False,
446-
compression: ParquetCompression | None = None,
447-
) -> DuckDBPyRelation: ...
448-
@typing.overload
449429
def read_parquet(
450430
self,
451-
file_globs: Sequence[str],
431+
path_or_buffer: str
432+
| bytes
433+
| os.PathLike[str]
434+
| os.PathLike[bytes]
435+
| typing.IO[bytes]
436+
| typing.IO[str]
437+
| Sequence[str | bytes | os.PathLike[str] | os.PathLike[bytes] | typing.IO[bytes] | typing.IO[str]],
452438
binary_as_string: bool = False,
453439
*,
454440
file_row_number: bool = False,
@@ -1061,21 +1047,14 @@ def from_csv_auto(
10611047
strict_mode: bool | None = None,
10621048
) -> DuckDBPyRelation: ...
10631049
def from_df(df: pandas.DataFrame, *, connection: DuckDBPyConnection | None = None) -> DuckDBPyRelation: ...
1064-
@typing.overload
1065-
def from_parquet(
1066-
file_glob: str,
1067-
binary_as_string: bool = False,
1068-
*,
1069-
file_row_number: bool = False,
1070-
filename: bool = False,
1071-
hive_partitioning: bool = False,
1072-
union_by_name: bool = False,
1073-
compression: ParquetCompression | None = None,
1074-
connection: DuckDBPyConnection | None = None,
1075-
) -> DuckDBPyRelation: ...
1076-
@typing.overload
10771050
def from_parquet(
1078-
file_globs: Sequence[str],
1051+
path_or_buffer: str
1052+
| bytes
1053+
| os.PathLike[str]
1054+
| os.PathLike[bytes]
1055+
| typing.IO[bytes]
1056+
| typing.IO[str]
1057+
| Sequence[str | bytes | os.PathLike[str] | os.PathLike[bytes] | typing.IO[bytes] | typing.IO[str]],
10791058
binary_as_string: bool = False,
10801059
*,
10811060
file_row_number: bool = False,
@@ -1232,21 +1211,14 @@ def read_json(
12321211
hive_types: HiveTypes | None = None,
12331212
hive_types_autocast: bool | None = None,
12341213
) -> DuckDBPyRelation: ...
1235-
@typing.overload
1236-
def read_parquet(
1237-
file_glob: str,
1238-
binary_as_string: bool = False,
1239-
*,
1240-
file_row_number: bool = False,
1241-
filename: bool = False,
1242-
hive_partitioning: bool = False,
1243-
union_by_name: bool = False,
1244-
compression: ParquetCompression | None = None,
1245-
connection: DuckDBPyConnection | None = None,
1246-
) -> DuckDBPyRelation: ...
1247-
@typing.overload
12481214
def read_parquet(
1249-
file_globs: Sequence[str],
1215+
path_or_buffer: str
1216+
| bytes
1217+
| os.PathLike[str]
1218+
| os.PathLike[bytes]
1219+
| typing.IO[bytes]
1220+
| typing.IO[str]
1221+
| Sequence[str | bytes | os.PathLike[str] | os.PathLike[bytes] | typing.IO[bytes] | typing.IO[str]],
12501222
binary_as_string: bool = False,
12511223
*,
12521224
file_row_number: bool = False,

cmake/duckdb_loader.cmake

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
# Simple DuckDB Build Configuration Module
44
#
55
# Sets sensible defaults for DuckDB Python extension builds and provides a clean
6-
# interface for adding DuckDB as a library target. Adds jemalloc option for
7-
# debugging but will never allow jemalloc in a release build if not on Linux.
6+
# interface for adding DuckDB as a library target.
87
#
98
# Usage: include(cmake/duckdb_loader.cmake) # Optionally load extensions
109
# set(BUILD_EXTENSIONS "json;parquet;icu")
@@ -108,37 +107,6 @@ set(DEBUG_STACKTRACE
108107
# Internal Functions
109108
# ════════════════════════════════════════════════════════════════════════════════
110109

111-
function(_duckdb_validate_jemalloc_config)
112-
# Check if jemalloc is in the extension list
113-
if(NOT BUILD_EXTENSIONS MATCHES "jemalloc")
114-
return()
115-
endif()
116-
117-
# jemalloc is only enabled on 64bit x86 linux builds
118-
if(CMAKE_SIZEOF_VOID_P EQUAL 8
119-
AND CMAKE_SYSTEM_NAME STREQUAL "Linux"
120-
AND NOT BSD)
121-
set(jemalloc_allowed TRUE)
122-
else()
123-
set(jemalloc_allowed FALSE)
124-
endif()
125-
126-
if(NOT jemalloc_allowed)
127-
message(WARNING "jemalloc extension is only supported on Linux.\n"
128-
"Removing jemalloc from extension list.")
129-
# Remove jemalloc from the extension list
130-
string(REPLACE "jemalloc" "" BUILD_EXTENSIONS_FILTERED
131-
"${BUILD_EXTENSIONS}")
132-
string(REGEX REPLACE ";+" ";" BUILD_EXTENSIONS_FILTERED
133-
"${BUILD_EXTENSIONS_FILTERED}")
134-
string(REGEX REPLACE "^;|;$" "" BUILD_EXTENSIONS_FILTERED
135-
"${BUILD_EXTENSIONS_FILTERED}")
136-
set(BUILD_EXTENSIONS
137-
"${BUILD_EXTENSIONS_FILTERED}"
138-
PARENT_SCOPE)
139-
endif()
140-
endfunction()
141-
142110
function(_duckdb_validate_source_path)
143111
if(NOT EXISTS "${DUCKDB_SOURCE_PATH}")
144112
message(
@@ -234,19 +202,13 @@ endfunction()
234202

235203
function(duckdb_add_library target_name)
236204
_duckdb_validate_source_path()
237-
_duckdb_validate_jemalloc_config()
238205
_duckdb_print_summary()
239206

240207
# Add DuckDB subdirectory - it will use our variables
241208
add_subdirectory("${DUCKDB_SOURCE_PATH}" duckdb EXCLUDE_FROM_ALL)
242209

243210
# Create clean interface target
244211
_duckdb_create_interface_target(${target_name})
245-
246-
# Propagate BUILD_EXTENSIONS back to caller scope in case it was modified
247-
set(BUILD_EXTENSIONS
248-
"${BUILD_EXTENSIONS}"
249-
PARENT_SCOPE)
250212
endfunction()
251213

252214
function(duckdb_link_extensions target_name)

duckdb/polars_io.py

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations # noqa: D100
22

3-
import contextlib
43
import datetime
4+
import io
55
import json
66
import typing
77
from decimal import Decimal
@@ -35,16 +35,95 @@ def _predicate_to_expression(predicate: pl.Expr) -> duckdb.Expression | None:
3535
"""
3636
# Serialize the Polars expression tree to JSON
3737
tree = json.loads(predicate.meta.serialize(format="json"))
38+
return _tree_to_sql_expression(tree)
3839

40+
41+
def _tree_to_sql_expression(tree: _ExpressionTree) -> duckdb.Expression | None:
42+
"""Convert an already-parsed Polars expression tree to a DuckDB expression.
43+
44+
Returns None if the tree contains a node we cannot translate to SQL.
45+
"""
3946
try:
40-
# Convert the tree to SQL
41-
sql_filter = _pl_tree_to_sql(tree)
42-
return duckdb.SQLExpression(sql_filter)
47+
return duckdb.SQLExpression(_pl_tree_to_sql(tree))
4348
except Exception:
4449
# If the conversion fails, we return None
4550
return None
4651

4752

53+
# Polars "dynamic predicates"
54+
# ---------------------------
55+
# When a slice / TOP-N sits above a scan, polars' optimizer pushes a *dynamic
56+
# predicate* into the scan, AND-ed onto any real filter. It is an internal
57+
# optimizer node, not a materializable expression: it serializes as a `Display`
58+
# node with `fmt_str` "dynamic_pred: <uuid>", and feeding it back into
59+
# `DataFrame.filter()` panics polars (`unreachable!` in `expr_to_ir`). It is only
60+
# an early-pruning hint -- the limit above the scan still runs -- so we drop it
61+
# and keep the real predicate, which we MUST still apply (polars trusts the
62+
# source and does not re-filter above it). Reported as duckdb-python#460; for
63+
# polars-side context see:
64+
# - https://github.com/pola-rs/polars/issues/21665 (why real + dynamic arrive AND-ed)
65+
# - https://github.com/pola-rs/polars/issues/22252 (filter() panicking on un-lowerable nodes)
66+
def _is_dynamic_predicate_node(node: typing.Any) -> bool: # noqa: ANN401
67+
"""Return True if a serialized node is a dynamic predicate (see the note above).
68+
69+
Detected by shape: a ``Display`` node whose ``fmt_str`` starts with ``dynamic_pred``.
70+
"""
71+
if not isinstance(node, dict):
72+
return False
73+
display = node.get("Display")
74+
return (
75+
isinstance(display, dict)
76+
and isinstance(display.get("fmt_str"), str)
77+
and display["fmt_str"].startswith("dynamic_pred")
78+
)
79+
80+
81+
def _tree_contains_dynamic_predicate(node: typing.Any) -> bool: # noqa: ANN401
82+
"""Return True if the serialized expression tree contains a dynamic predicate anywhere."""
83+
if _is_dynamic_predicate_node(node):
84+
return True
85+
if isinstance(node, dict):
86+
return any(_tree_contains_dynamic_predicate(child) for child in node.values())
87+
if isinstance(node, list):
88+
return any(_tree_contains_dynamic_predicate(child) for child in node)
89+
return False
90+
91+
92+
def _strip_dynamic_predicates(tree: typing.Any) -> tuple[_ExpressionTree | None, bool]: # noqa: ANN401
93+
"""Remove dynamic-predicate conjuncts from a serialized predicate tree.
94+
95+
See the note above for what a dynamic predicate is and why we drop it.
96+
97+
Returns ``(stripped_tree, removed)``. ``stripped_tree`` is ``None`` when the
98+
predicate was purely dynamic. Raises ``NotImplementedError`` if a dynamic
99+
predicate appears anywhere other than a top-level ``And`` conjunct — a shape
100+
polars does not produce today, where the hint can neither be safely dropped
101+
nor applied.
102+
"""
103+
if _is_dynamic_predicate_node(tree):
104+
return None, True
105+
if isinstance(tree, dict) and "BinaryExpr" in tree:
106+
bin_expr = tree["BinaryExpr"]
107+
if isinstance(bin_expr, dict) and bin_expr.get("op") == "And":
108+
left, left_removed = _strip_dynamic_predicates(bin_expr["left"])
109+
right, right_removed = _strip_dynamic_predicates(bin_expr["right"])
110+
removed = left_removed or right_removed
111+
if left is None:
112+
return right, removed
113+
if right is None:
114+
return left, removed
115+
return {"BinaryExpr": {**bin_expr, "left": left, "right": right}}, removed
116+
if _tree_contains_dynamic_predicate(tree):
117+
msg = "Cannot handle a polars dynamic predicate outside a top-level AND conjunct"
118+
raise NotImplementedError(msg)
119+
return tree, False
120+
121+
122+
def _expression_from_tree(tree: _ExpressionTree) -> pl.Expr:
123+
"""Rebuild a polars expression from a serialized tree (inverse of meta.serialize)."""
124+
return pl.Expr.deserialize(io.BytesIO(json.dumps(tree).encode()), format="json")
125+
126+
48127
def _pl_operation_to_sql(op: str) -> str:
49128
"""Map Polars binary operation strings to SQL equivalents.
50129
@@ -286,26 +365,36 @@ def source_generator(
286365
batch_size: int | None,
287366
) -> Iterator[pl.DataFrame]:
288367
duck_predicate = None
368+
fallback_predicate = None
289369
relation_final = relation
290370
if with_columns is not None:
291371
cols = ",".join(map(_escape_sql_identifier, with_columns))
292372
relation_final = relation_final.project(cols)
293373
if n_rows is not None:
294374
relation_final = relation_final.limit(n_rows)
295375
if predicate is not None:
296-
# We have a predicate, if possible, we push it down to DuckDB
297-
with contextlib.suppress(AssertionError, KeyError):
298-
duck_predicate = _predicate_to_expression(predicate)
376+
# Strip any dynamic-predicate hint (see the dynamic-predicate note
377+
# above); the real predicate must still be applied.
378+
tree = json.loads(predicate.meta.serialize(format="json"))
379+
real_tree, had_dynamic = _strip_dynamic_predicates(tree)
380+
if real_tree is not None:
381+
# We have a real predicate; if possible, push it down to DuckDB.
382+
duck_predicate = _tree_to_sql_expression(real_tree)
383+
if duck_predicate is None:
384+
# Could not push it down: re-apply it polars-side. Rebuild the
385+
# expression from the stripped tree so we never hand polars the
386+
# dynamic node it cannot lower.
387+
fallback_predicate = _expression_from_tree(real_tree) if had_dynamic else predicate
299388
# Try to pushdown filter, if one exists
300389
if duck_predicate is not None:
301390
relation_final = relation_final.filter(duck_predicate)
302391
results = relation_final.to_arrow_reader() if batch_size is None else relation_final.to_arrow_reader(batch_size)
303392

304393
for record_batch in iter(results.read_next_batch, None):
305-
if predicate is not None and duck_predicate is None:
394+
if fallback_predicate is not None:
306395
# We have a predicate, but did not manage to push it down, we fallback here
307-
yield pl.from_arrow(record_batch).filter(predicate) # type: ignore[arg-type,misc,unused-ignore]
396+
yield pl.from_arrow(record_batch).filter(fallback_predicate) # type: ignore[arg-type,misc,unused-ignore]
308397
else:
309398
yield pl.from_arrow(record_batch) # type: ignore[misc,unused-ignore]
310399

311-
return register_io_source(source_generator, schema=schema)
400+
return register_io_source(source_generator, schema=schema, is_pure=True)

external/duckdb

Submodule duckdb updated 319 files

pyproject.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ packages.adbc_driver_duckdb = "adbc_driver_duckdb"
8181
packages._duckdb-stubs = "_duckdb-stubs"
8282

8383
[tool.scikit-build.cmake.define]
84-
BUILD_EXTENSIONS = "core_functions;json;parquet;icu;jemalloc"
84+
BUILD_EXTENSIONS = "core_functions;json;parquet;icu"
8585

8686
[tool.setuptools_scm]
8787
version_scheme = "duckdb_packaging.setuptools_scm_version:version_scheme"
@@ -229,7 +229,7 @@ stubdeps = [ # dependencies used for typehints in the stubs
229229
"mypy",
230230
"fsspec",
231231
"pandas",
232-
"polars",
232+
"polars>=1.33.0",
233233
"pyarrow; sys_platform != 'win32' or platform_machine != 'ARM64'",
234234
"typing-extensions",
235235
]
@@ -241,10 +241,10 @@ test = [ # dependencies used for running tests
241241
"pytest-timeout",
242242
"pytest-timestamper",
243243
"coverage",
244-
"gcovr",
244+
"gcovr; sys_platform != 'win32' or platform_machine != 'ARM64'",
245245
"gcsfs; sys_platform != 'win32' or platform_machine != 'ARM64'",
246246
"packaging",
247-
"polars",
247+
"polars>=1.33.0",
248248
"psutil",
249249
"py4j",
250250
"pyotp",
@@ -276,7 +276,7 @@ scripts = [ # dependencies used for running scripts
276276
"numpy",
277277
"pandas",
278278
"pcpp",
279-
"polars",
279+
"polars>=1.33.0",
280280
"pyarrow; sys_platform != 'win32' or platform_machine != 'ARM64'",
281281
"pytz"
282282
]

0 commit comments

Comments
 (0)