|
1 | 1 | import datetime |
2 | 2 | import logging |
| 3 | +import os |
3 | 4 | import warnings |
4 | 5 | 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 |
6 | 7 |
|
7 | 8 | import orjson |
8 | 9 | import polars as pl |
9 | 10 | 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 |
10 | 16 | from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, computed_field, model_validator |
11 | 17 |
|
12 | 18 | from .enum import ( |
@@ -1055,6 +1061,253 @@ def _parse_anon_function(self, expr: pl.Expr, expr_dict: Dict) -> BaseExprNode: |
1055 | 1061 | return AnonymousFunctionNode(expr=expr, input=input_node) |
1056 | 1062 |
|
1057 | 1063 |
|
| 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 | + |
1058 | 1311 | def get_literal_value(expr: pl.Expr) -> Any: |
1059 | 1312 | """Extract the value from a Polars expression as a python object. This does not neccessarily have to be a polars literal expression itself. |
1060 | 1313 |
|
@@ -1111,10 +1364,16 @@ def extract_column_name(node: BaseExprNode) -> Optional[str]: |
1111 | 1364 |
|
1112 | 1365 |
|
1113 | 1366 | 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 | + """ |
1115 | 1371 | if isinstance(expr_or_node, BaseExprNode): |
1116 | 1372 | 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) |
1118 | 1377 |
|
1119 | 1378 |
|
1120 | 1379 | def convert_datetime_to_polars(dt_val: datetime.datetime, schema_cast: Optional[Any] = None) -> pl.Expr: |
|
0 commit comments