Source code for process_improve.workbench._expressions

# (c) Kevin Dunn, 2010-2026. MIT License.
"""The expression language of a recipe's computed columns and row filters.

An expression is parsed with :mod:`ast` and walked, never evaluated as Python, so an
expression read from someone else's project file is data. The same walk gives the
value (:func:`evaluate`) and the pandas code that computes it (:func:`to_code`), so the
code a recipe writes does exactly what the recipe did, operation for operation.

Accepted: numbers, column names, ``+ - * / **``, one comparison (``< <= > >= == !=``)
against a number or a quoted text, ``&``, ``|`` and ``~`` to combine conditions, and
the functions in :data:`FUNCTIONS`. ``and``, ``or`` and ``not`` combine conditions as a
reader expects (``Tmax1 > 290 and Mn < 30000``) and are written as pandas' ``&``, ``|``
and ``~``. A column name that is not a Python identifier is
written in back-ticks, as in :meth:`pandas.DataFrame.query`: ``` `Real coffee` * 2 ```.
"""

from __future__ import annotations

import ast
import functools
import io
import keyword
import operator
import re
import tokenize
import unicodedata
from typing import TYPE_CHECKING, Any, cast

import numpy as np

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable

    import pandas as pd

#: Longest expression accepted, which also bounds the depth of the parse.
MAX_EXPRESSION_LENGTH = 500

#: The functions an expression may call, each with one argument, and the code that calls it.
FUNCTIONS: dict[str, tuple[Callable[[Any], Any], str]] = {
    "abs": (np.abs, "np.abs"),
    "exp": (np.exp, "np.exp"),
    "log": (np.log, "np.log"),
    "log10": (np.log10, "np.log10"),
    "log1p": (np.log1p, "np.log1p"),
    "sign": (np.sign, "np.sign"),
    "sqrt": (np.sqrt, "np.sqrt"),
}

_BINARY: dict[type[ast.operator], tuple[Callable[[Any, Any], Any], str]] = {
    ast.Add: (operator.add, "+"),
    ast.Sub: (operator.sub, "-"),
    ast.Mult: (operator.mul, "*"),
    ast.Div: (operator.truediv, "/"),
    ast.Pow: (operator.pow, "**"),
    ast.BitAnd: (operator.and_, "&"),
    ast.BitOr: (operator.or_, "|"),
}
_UNARY: dict[type[ast.unaryop], tuple[Callable[[Any], Any], str]] = {
    ast.USub: (operator.neg, "-"),
    ast.UAdd: (operator.pos, "+"),
    ast.Invert: (operator.invert, "~"),
    ast.Not: (operator.invert, "~"),
}
# ``and`` / ``or`` bind after the comparisons, as a reader expects, and become pandas' ``&`` / ``|``.
_BOOLEAN: dict[type[ast.boolop], tuple[Callable[[Any, Any], Any], str]] = {
    ast.And: (operator.and_, "&"),
    ast.Or: (operator.or_, "|"),
}
_COMPARE: dict[type[ast.cmpop], tuple[Callable[[Any, Any], Any], str]] = {
    ast.Lt: (operator.lt, "<"),
    ast.LtE: (operator.le, "<="),
    ast.Gt: (operator.gt, ">"),
    ast.GtE: (operator.ge, ">="),
    ast.Eq: (operator.eq, "=="),
    ast.NotEq: (operator.ne, "!="),
}
_QUOTED = re.compile(r"`([^`]*)`")
_PLACEHOLDER = "__column_{}__"


[docs] class Expression: """A parsed, checked expression over the columns of a table. Parameters ---------- text : str The expression as typed. columns : Iterable[str] or None The columns it may name. ``None`` takes every name in the text as a column, as typed: what writing the code of an expression already checked against its table needs. Raises ------ ValueError If the text is too long, does not parse, uses syntax outside the language, or names a column that is not in ``columns``. """ def __init__(self, text: str, columns: Iterable[str] | None) -> None: if len(text) > MAX_EXPRESSION_LENGTH: raise ValueError(f"An expression may be at most {MAX_EXPRESSION_LENGTH} characters long.") self.text = text quoted: list[str] = [] def placeholder(match: re.Match[str]) -> str: quoted.append(match.group(1)) return _PLACEHOLDER.format(len(quoted) - 1) source = _QUOTED.sub(placeholder, text) try: self._tree = ast.parse(source.strip(), mode="eval").body except SyntaxError as error: raise ValueError(f"The expression {text!r} does not parse: {error.msg}.") from None # Python's parser writes every identifier in its NFKC form, so a plain name is # matched on that form; a back-ticked name is matched exactly as written. known = list(columns) if columns is not None else [*_typed_names(source.strip()), *quoted] lookup = {unicodedata.normalize("NFKC", name): name for name in known} lookup.update({_PLACEHOLDER.format(i): name for i, name in enumerate(quoted)}) self._names = lookup self._quoted = quoted self._known = set(known) self.columns = self._check(self._tree) def _check(self, node: ast.expr, *, text_allowed: bool = False) -> list[str]: """Return the columns ``node`` names, in order, refusing anything outside the language. ``text_allowed`` admits a quoted text, which only the right side of a comparison may be. """ if isinstance(node, ast.Constant) and ( _is_number(node.value) or (text_allowed and isinstance(node.value, str)) ): return [] if isinstance(node, ast.Name): return [self._column(node.id)] if isinstance(node, ast.Compare) and len(node.ops) > 1: raise ValueError( f"{self.text!r} chains comparisons. Join them with 'and' or 'or', for example " "'Tmax1 > 290 and Mn < 30000', or put each one in parentheses." ) operation = _operation(node) if operation is None: shown = re.sub(r"__column_(\d+)__", lambda m: f"`{self._quoted[int(m.group(1))]}`", ast.unparse(node)) raise ValueError(f"{shown!r} is not allowed in an expression; see the expression language.") children = operation[2] compared = isinstance(node, ast.Compare) return [ name for i, child in enumerate(children) for name in self._check(child, text_allowed=compared and i == 1) ] def _column(self, parsed: str) -> str: name = self._names.get(parsed) if name is None or name not in self._known: missing = name if name is not None else parsed raise ValueError(f"The expression names {missing!r}, which is not a column.") return name
[docs] def evaluate(self, frame: pd.DataFrame) -> Any: # noqa: ANN401 - a Series, or a number for a constant expression """Return the expression's value over ``frame``: a Series, one value per row.""" return self._value(self._tree, frame)
def _value(self, node: ast.expr, frame: pd.DataFrame) -> Any: # noqa: ANN401 if isinstance(node, ast.Constant): return node.value if isinstance(node, ast.Name): return frame[self._names[node.id]] function, _, children = cast("_Operation", _operation(node)) # _check admitted it return function(*(self._value(child, frame) for child in children))
[docs] def to_code(self, frame: str = "df") -> str: """Return the pandas code that computes the expression over the table named ``frame``. Every operation is parenthesised, so the code groups exactly as the parse did. """ return self._code(self._tree, frame)
def _code(self, node: ast.expr, frame: str) -> str: if isinstance(node, ast.Constant): return python_literal(node.value) if isinstance(node, ast.Name): return f"{frame}[{python_literal(self._names[node.id])}]" _, write, children = cast("_Operation", _operation(node)) # _check admitted it return write([self._code(child, frame) for child in children])
#: An operation: the function that computes it, how its code joins its operands' code, and its operands. _Operation = tuple["Callable[..., Any]", "Callable[[list[str]], str]", list[ast.expr]] def _operation(node: ast.expr) -> _Operation | None: """Return the operation at ``node``, or None when the language has no such operation.""" fn: Callable[..., Any] if isinstance(node, ast.BinOp) and type(node.op) in _BINARY: fn, symbol = _BINARY[type(node.op)] return fn, lambda c: f"({c[0]} {symbol} {c[1]})", [node.left, node.right] if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY: fn, symbol = _UNARY[type(node.op)] return fn, lambda c: f"({symbol}{c[0]})", [node.operand] if isinstance(node, ast.BoolOp) and type(node.op) in _BOOLEAN: fn, symbol = _BOOLEAN[type(node.op)] return lambda *v: functools.reduce(fn, v), lambda c: "(" + f" {symbol} ".join(c) + ")", list(node.values) if isinstance(node, ast.Compare) and len(node.ops) == 1 and type(node.ops[0]) in _COMPARE: fn, symbol = _COMPARE[type(node.ops[0])] return fn, lambda c: f"({c[0]} {symbol} {c[1]})", [node.left, node.comparators[0]] if ( isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in FUNCTIONS and len(node.args) == 1 and not node.keywords ): fn, name = FUNCTIONS[node.func.id] return fn, lambda c: f"{name}({c[0]})", [node.args[0]] return None def _typed_names(source: str) -> list[str]: """Return the names in ``source`` as typed, before the parser rewrites them in NFKC form. Called only after ``ast.parse`` accepted ``source``, so the tokenizer cannot fail on it. """ tokens = tokenize.generate_tokens(io.StringIO(source).readline) return [t.string for t in tokens if t.type == tokenize.NAME and not keyword.iskeyword(t.string)] def _is_number(value: object) -> bool: return isinstance(value, int | float) and not isinstance(value, bool)
[docs] def python_literal(value: object) -> str: """Return ``value`` as Python source: text in double quotes where it can be, numbers exactly.""" if isinstance(value, str): text = repr(value) if text.startswith("'") and '"' not in value: return f'"{text[1:-1]}"' return text if isinstance(value, list | tuple): items = ", ".join(python_literal(v) for v in value) return f"({items},)" if isinstance(value, tuple) and len(value) == 1 else f"[{items}]" if isinstance(value, dict): return "{" + ", ".join(f"{python_literal(k)}: {python_literal(v)}" for k, v in value.items()) + "}" if isinstance(value, float) and not np.isfinite(value): return f"float({str(value)!r})" return repr(value)