Skip to content

Commit 39b5719

Browse files
ptomecekCopilot
andcommitted
Add NodeTraverser-based expression parser (default) with legacy escape hatch
Adds a `NodeTraverserParser` built on polars' `_plr` API (`LazyFrame._ldf.visit()` + `add_expressions` + `view_expression`). It produces the same `BaseExprNode` tree the JSON-serialization based `ExprParser` does, so it plugs in behind `get_parsed_expr` without changing the downstream visitor surface. The new parser is the default. Set `POLARS_IO_TOOLS_USE_LEGACY_EXPR_PARSER=1` to fall back to the legacy parser for one release as an escape hatch. The new parser walks the post-optimization typed expression view, which is exactly the shape that reaches a custom IO source predicate via `register_io_source`. Several expression shapes the legacy parser handles (top-level/nested aliases, any_horizontal / all_horizontal, selectors / wildcards, list / struct / explode) are rewritten away by the polars optimizer before any predicate callback runs, so they are dead code on the predicate-pushdown path. The tests covering those shapes are marked `skip_unoptimized_expression_shape` and run only under the legacy escape hatch. Imports the parser node module via `polars._plr._expr_nodes` with a fallback to the older `polars.polars._expr_nodes` path (renamed in polars 1.32), and pins `polars>=1.8` to match the validated floor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Pascal Tomecek <40371786+ptomecek@users.noreply.github.com>
1 parent 9b60777 commit 39b5719

6 files changed

Lines changed: 325 additions & 6 deletions

File tree

polars_io_tools/io_sources/base.py

Lines changed: 262 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import datetime
22
import logging
3+
import os
34
import warnings
45
from abc import ABC, abstractmethod
5-
from typing import Any, Dict, Generic, List, Optional, TypeVar, Union
6+
from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar, Union
67

78
import orjson
89
import polars as pl
910
from packaging import version
11+
12+
try:
13+
from polars._plr import _expr_nodes as en # polars >= 1.32
14+
except ImportError:
15+
from polars.polars import _expr_nodes as en # polars < 1.32
1016
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, computed_field, model_validator
1117

1218
from .enum import (
@@ -1055,6 +1061,253 @@ def _parse_anon_function(self, expr: pl.Expr, expr_dict: Dict) -> BaseExprNode:
10551061
return AnonymousFunctionNode(expr=expr, input=input_node)
10561062

10571063

1064+
# ---------------------------------------------------------------------------
1065+
# NodeTraverser-based parser
1066+
#
1067+
# Built on polars' private ``NodeTraverser`` API (``LazyFrame._ldf.visit()`` plus ``add_expressions`` and ``view_expression``). The traverser exposes
1068+
# a typed view of expression nodes after polars' DSL→IR conversion, which is exactly the shape that arrives at a custom IO source plugin via
1069+
# ``polars.io.plugins.register_io_source``. Structural traversal is driven by ``expr.meta.pop()`` so each sub-node receives an accurate ``pl.Expr``
1070+
# (downstream visitors rely on ``node.expr.meta.root_names()`` and ``~node.expr``); the traverser is used purely for typed dispatch and for direct
1071+
# access to ``Operator``, function-category enums, ``Literal.value``/``dtype`` and ``Cast.dtype``. This is the default parser; the legacy
1072+
# JSON-serialization based :class:`ExprParser` above remains reachable for one release via the ``POLARS_IO_TOOLS_USE_LEGACY_EXPR_PARSER`` env var.
1073+
# ---------------------------------------------------------------------------
1074+
1075+
_OPERATOR_NAME_MAP: Dict[str, OperatorType] = {op.value: op for op in OperatorType}
1076+
1077+
# (polars typed enum class, category key consumed by `get_function_enum`). Order does not matter; lookup is by isinstance on the head of
1078+
# `Function.function_data`. Some classes (e.g. `StructFunction`, added in polars 1.31) may be absent on older supported polars versions;
1079+
# `getattr` keeps the table compatible across the supported range and the missing categories fall through to `UNKNOWN`.
1080+
_FUNCTION_CATEGORY_MAP: List[Tuple[type, str]] = [
1081+
(cls, key)
1082+
for cls, key in [
1083+
(getattr(en, "BooleanFunction", None), "Boolean"),
1084+
(getattr(en, "StringFunction", None), "StringExpr"),
1085+
(getattr(en, "TemporalFunction", None), "TemporalExpr"),
1086+
(getattr(en, "StructFunction", None), "StructExpr"),
1087+
]
1088+
if cls is not None
1089+
]
1090+
1091+
# NodeTraverser returns lowercase `closed` strings; downstream visitors (range_visitor, dnf_visitor, sql_utils, translated_source) expect
1092+
# capitalized variants matching the legacy JSON parser's output.
1093+
_CLOSED_MAP = {"both": "Both", "left": "Left", "right": "Right", "none": "None"}
1094+
1095+
1096+
class NodeTraverserParser:
1097+
"""Parse a :class:`pl.Expr` into a :class:`BaseExprNode` tree using polars' ``NodeTraverser`` for typed dispatch and ``expr.meta.pop()``
1098+
for sub-expression attribution.
1099+
1100+
Mirrors the structure of :class:`ExprParser` above (dispatch via a class-keyed parser map, one ``_parse_<kind>`` method per node type) so
1101+
that the eventual Phase B swap is a near-mechanical replacement.
1102+
"""
1103+
1104+
def __init__(self) -> None:
1105+
# Dispatch on the polars typed-view class, mirroring ExprParser._parser_map.
1106+
self._parser_map: Dict[type, Any] = {
1107+
en.Column: self._parse_column,
1108+
en.Literal: self._parse_literal,
1109+
en.Len: self._parse_len,
1110+
en.Cast: self._parse_cast,
1111+
en.Alias: self._parse_alias,
1112+
en.Sort: self._parse_sort,
1113+
en.BinaryExpr: self._parse_binary_expr,
1114+
en.Filter: self._parse_filter,
1115+
en.Gather: self._parse_gather,
1116+
en.SortBy: self._parse_sort_by,
1117+
en.Ternary: self._parse_ternary,
1118+
en.Slice: self._parse_slice,
1119+
en.Function: self._parse_function_expr,
1120+
}
1121+
1122+
def parse(self, expr: pl.Expr) -> BaseExprNode:
1123+
"""Parse a Polars expression into structured node types via the NodeTraverser API."""
1124+
try:
1125+
visitor = self._build_visitor(expr)
1126+
return self._parse(expr, visitor)
1127+
except Exception as e:
1128+
log.warning("NodeTraverserParser unexpected failure: %s", e, exc_info=True)
1129+
return ErrorNode(expr=expr, error=str(e))
1130+
1131+
def _build_visitor(self, expr: pl.Expr) -> Any:
1132+
schema = {name: pl.Null for name in expr.meta.root_names()}
1133+
return pl.LazyFrame(schema=schema)._ldf.visit()
1134+
1135+
def _view(self, visitor: Any, expr: pl.Expr) -> Optional[Any]:
1136+
"""Add ``expr`` to the traverser and return its typed view. Returns ``None`` if polars refuses or hasn't implemented the node — caller
1137+
falls back to :class:`UnknownNode`.
1138+
"""
1139+
try:
1140+
[node_id], _ = visitor.add_expressions([expr._pyexpr])
1141+
except Exception:
1142+
return None
1143+
try:
1144+
return visitor.view_expression(node_id)
1145+
except NotImplementedError:
1146+
return None
1147+
1148+
def _parse(self, expr: pl.Expr, visitor: Any) -> BaseExprNode:
1149+
"""Parse a single expression node by dispatching on the polars typed view's class.
1150+
1151+
Per-subtree errors are isolated here (matching the legacy ``ExprParser``'s per-method try/except behavior): a failure in one
1152+
``_parse_<kind>`` method collapses that subtree to an :class:`ErrorNode` rather than the whole top-level expression.
1153+
"""
1154+
obj = self._view(visitor, expr)
1155+
if obj is None:
1156+
# polars' NodeTraverser has no typed view for a few IR nodes that can appear in a pushed-down
1157+
# predicate -- notably list functions (``col.list.*``) and array functions (``col.arr.*``), which
1158+
# raise ``NotImplementedError`` from ``view_expression``. Those subtrees degrade to ``UnknownNode``
1159+
# here, so predicates containing them are not pushed down under this parser (the legacy parser, via
1160+
# ``POLARS_IO_TOOLS_USE_LEGACY_EXPR_PARSER=1``, still handles them). Recovering them requires polars to
1161+
# expose ``ListFunction`` / ``ArrayFunction`` typed views *and* a matching entry in
1162+
# ``_FUNCTION_CATEGORY_MAP`` here; it will not resolve automatically from the polars change alone.
1163+
return UnknownNode(expr=expr, data={"reason": "node_traverser_refused"})
1164+
handler = self._parser_map.get(type(obj))
1165+
if handler is None:
1166+
return UnknownNode(expr=expr, data={"node_type": type(obj).__name__})
1167+
try:
1168+
return handler(expr, obj, visitor)
1169+
except Exception as e:
1170+
return ErrorNode(expr=expr, error=str(e))
1171+
1172+
# Leaves
1173+
def _parse_column(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1174+
return ColumnNode(expr=expr, name=obj.name)
1175+
1176+
def _parse_literal(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1177+
return LiteralNode(expr=expr)
1178+
1179+
def _parse_len(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1180+
return LenNode(expr=expr)
1181+
1182+
# Single-input wrappers
1183+
def _parse_cast(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1184+
(input_expr,) = expr.meta.pop()
1185+
return CastNode(expr=expr, input=self._parse(input_expr, visitor), dtype=obj.dtype)
1186+
1187+
def _parse_alias(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1188+
(input_expr,) = expr.meta.pop()
1189+
return AliasNode(expr=expr, input=self._parse(input_expr, visitor), name=obj.name)
1190+
1191+
def _parse_sort(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1192+
(input_expr,) = expr.meta.pop()
1193+
return SortNode(expr=expr, input=self._parse(input_expr, visitor), options={"options": obj.options})
1194+
1195+
# Two-input
1196+
def _parse_binary_expr(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1197+
children = list(reversed(expr.meta.pop()))
1198+
if len(children) != 2:
1199+
return ErrorNode(expr=expr, error=f"BinaryExpr expected 2 children, got {len(children)}")
1200+
op_name = repr(obj.op).rsplit(".", 1)[-1]
1201+
op = _OPERATOR_NAME_MAP.get(op_name)
1202+
if op is None:
1203+
return UnknownNode(expr=expr, data={"unknown_op": op_name})
1204+
return BinaryExprNode(expr=expr, left=self._parse(children[0], visitor), op=op, right=self._parse(children[1], visitor))
1205+
1206+
def _parse_filter(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1207+
children = list(expr.meta.pop())
1208+
if len(children) < 2:
1209+
return ErrorNode(expr=expr, error="Filter expected 2 children")
1210+
return FilterNode(expr=expr, input=self._parse(children[0], visitor), by=self._parse(children[1], visitor))
1211+
1212+
def _parse_gather(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1213+
children = list(expr.meta.pop())
1214+
if len(children) < 2:
1215+
return ErrorNode(expr=expr, error="Gather expected 2 children")
1216+
return GatherNode(
1217+
expr=expr,
1218+
input=self._parse(children[0], visitor),
1219+
idx=self._parse(children[1], visitor),
1220+
returns_scalar=bool(obj.scalar),
1221+
)
1222+
1223+
def _parse_sort_by(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1224+
children = list(expr.meta.pop())
1225+
if len(children) < 2:
1226+
return ErrorNode(expr=expr, error="SortBy expected >=2 children")
1227+
return SortByNode(
1228+
expr=expr,
1229+
input=self._parse(children[0], visitor),
1230+
by=[self._parse(c, visitor) for c in children[1:]],
1231+
options={"sort_options": obj.sort_options},
1232+
)
1233+
1234+
# Three-input
1235+
def _parse_ternary(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1236+
children = list(expr.meta.pop())
1237+
if len(children) != 3:
1238+
return ErrorNode(expr=expr, error="Ternary expected 3 children")
1239+
predicate_expr, falsy_expr, truthy_expr = children
1240+
return TernaryNode(
1241+
expr=expr,
1242+
predicate=self._parse(predicate_expr, visitor),
1243+
truthy=self._parse(truthy_expr, visitor),
1244+
falsy=self._parse(falsy_expr, visitor),
1245+
)
1246+
1247+
def _parse_slice(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1248+
children = list(expr.meta.pop())
1249+
if len(children) != 3:
1250+
return ErrorNode(expr=expr, error="Slice expected 3 children")
1251+
return SliceNode(
1252+
expr=expr,
1253+
input=self._parse(children[0], visitor),
1254+
offset=self._parse(children[1], visitor),
1255+
length=self._parse(children[2], visitor),
1256+
)
1257+
1258+
# Variadic
1259+
def _parse_function_expr(self, expr: pl.Expr, obj: Any, visitor: Any) -> BaseExprNode:
1260+
child_exprs = list(reversed(expr.meta.pop()))
1261+
fn_enum, options = self._function_type_and_options(obj.function_data)
1262+
return FunctionNode(
1263+
expr=expr,
1264+
function_type=fn_enum,
1265+
inputs=[self._parse(c, visitor) for c in child_exprs],
1266+
options=options,
1267+
)
1268+
1269+
def _function_type_and_options(self, function_data: Tuple[Any, ...]) -> Tuple[FunctionType, Dict[str, Any]]:
1270+
"""Resolve a ``Function.function_data`` tuple to ``(our function-type enum, options dict)``.
1271+
1272+
Only the option keys that downstream consumers actually inspect are populated. In production these are ``closed`` (range/dnf/sql)
1273+
and ``literal`` (sql); ``nulls_equal`` and ``strict`` are emitted for parity with one unmarked test assertion. Everything else
1274+
collapses to ``{}`` — the legacy parser's broader option dict is dead code on the predicate-pushdown path.
1275+
"""
1276+
if not function_data:
1277+
return GenericFunctionType.UNKNOWN, {}
1278+
head, args = function_data[0], function_data[1:]
1279+
for polars_cls, category in _FUNCTION_CATEGORY_MAP:
1280+
if isinstance(head, polars_cls):
1281+
name = repr(head).rsplit(".", 1)[-1]
1282+
fn_enum = get_function_enum(category, name) or GenericFunctionType.UNKNOWN
1283+
return fn_enum, _named_function_options(category, name, args)
1284+
if isinstance(head, str):
1285+
name_pascal = "".join(part.capitalize() for part in head.split("_"))
1286+
try:
1287+
return GenericFunctionType(name_pascal), {}
1288+
except ValueError:
1289+
return GenericFunctionType.UNKNOWN, {}
1290+
return GenericFunctionType.UNKNOWN, {}
1291+
1292+
1293+
def _named_function_options(category: str, name: str, args: Tuple[Any, ...]) -> Dict[str, Any]:
1294+
if not args:
1295+
return {}
1296+
if category == "Boolean" and name == "IsBetween":
1297+
closed = args[0]
1298+
if isinstance(closed, str):
1299+
closed = _CLOSED_MAP.get(closed, closed)
1300+
return {"closed": closed}
1301+
if category == "Boolean" and name == "IsIn":
1302+
return {"nulls_equal": args[0]}
1303+
if category == "StringExpr" and name == "Contains":
1304+
out: Dict[str, Any] = {"literal": args[0]}
1305+
if len(args) >= 2:
1306+
out["strict"] = args[1]
1307+
return out
1308+
return {}
1309+
1310+
10581311
def get_literal_value(expr: pl.Expr) -> Any:
10591312
"""Extract the value from a Polars expression as a python object. This does not neccessarily have to be a polars literal expression itself.
10601313
@@ -1111,10 +1364,16 @@ def extract_column_name(node: BaseExprNode) -> Optional[str]:
11111364

11121365

11131366
def get_parsed_expr(expr_or_node: Union[pl.Expr, BaseExprNode]) -> Optional[BaseExprNode]:
1114-
"""Parse a Polars expression or return the node if it's already parsed. This is designed to be run after polars has applied its optimizations, such as the predicate passed to a custom io plugin. Thus, we avoid handling some Polars expressions that cannot be passed as such, for example, like polars window functions or aggregations."""
1367+
"""Parse a Polars expression or return the node if it's already parsed. This is designed to be run after polars has applied its optimizations, such as the predicate passed to a custom io plugin. Thus, we avoid handling some Polars expressions that cannot be passed as such, for example, like polars window functions or aggregations.
1368+
1369+
Set ``POLARS_IO_TOOLS_USE_LEGACY_EXPR_PARSER=1`` to fall back to the legacy parser if you hit a problem with the default.
1370+
"""
11151371
if isinstance(expr_or_node, BaseExprNode):
11161372
return expr_or_node
1117-
return ExprParser().parse(expr_or_node)
1373+
1374+
if os.environ.get("POLARS_IO_TOOLS_USE_LEGACY_EXPR_PARSER") == "1":
1375+
return ExprParser().parse(expr_or_node)
1376+
return NodeTraverserParser().parse(expr_or_node)
11181377

11191378

11201379
def convert_datetime_to_polars(dt_val: datetime.datetime, schema_cast: Optional[Any] = None) -> pl.Expr:

polars_io_tools/tests/io_sources/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# These type hints match the ones in polars_io_tools.io_sources.dnf_visitor
22
# but they are not imported here to fully separate the polars_utils code
33
# from the general utils tests.
4+
import os
45
from datetime import date, datetime, timedelta
56
from typing import Any, List, Optional, Tuple
67

@@ -23,6 +24,16 @@
2324
"tester",
2425
"assert_dnf_equal",
2526
"io_source_assert",
27+
"skip_unoptimized_expression_shape",
28+
)
29+
30+
31+
# Marker for tests that exercise expression shapes (top-level / nested aliases, ``any_horizontal`` / ``all_horizontal``, multi-predicate
32+
# ``pl.when(...)``, list / struct / explode) which polars' optimizer rewrites away before a predicate ever reaches a custom IO source plugin.
33+
# Only the legacy parser observes those shapes; set ``POLARS_IO_TOOLS_USE_LEGACY_EXPR_PARSER=1`` to run them.
34+
skip_unoptimized_expression_shape = pytest.mark.skipif(
35+
os.environ.get("POLARS_IO_TOOLS_USE_LEGACY_EXPR_PARSER") != "1",
36+
reason="Test exercises an expression shape that the polars optimizer rewrites away before reaching an IO source predicate.",
2637
)
2738

2839

0 commit comments

Comments
 (0)