1"""
2Operator classes for eval.
3"""
4
5from __future__ import annotations
6
7from datetime import datetime
8from functools import partial
9import operator
10from typing import (
11 TYPE_CHECKING,
12 Literal,
13)
14
15import numpy as np
16
17from pandas._libs.tslibs import Timestamp
18
19from pandas.core.dtypes.common import (
20 is_list_like,
21 is_scalar,
22)
23
24import pandas.core.common as com
25from pandas.core.computation.common import (
26 ensure_decoded,
27 result_type_many,
28)
29from pandas.core.computation.scope import DEFAULT_GLOBALS
30
31from pandas.io.formats.printing import (
32 pprint_thing,
33 pprint_thing_encoded,
34)
35
36if TYPE_CHECKING:
37 from collections.abc import (
38 Callable,
39 Iterable,
40 Iterator,
41 )
42
43REDUCTIONS = ("sum", "prod", "min", "max")
44
45_unary_math_ops = (
46 "sin",
47 "cos",
48 "tan",
49 "exp",
50 "log",
51 "expm1",
52 "log1p",
53 "sqrt",
54 "sinh",
55 "cosh",
56 "tanh",
57 "arcsin",
58 "arccos",
59 "arctan",
60 "arccosh",
61 "arcsinh",
62 "arctanh",
63 "abs",
64 "log10",
65 "floor",
66 "ceil",
67)
68_binary_math_ops = ("arctan2",)
69
70MATHOPS = _unary_math_ops + _binary_math_ops
71
72
73LOCAL_TAG = "__pd_eval_local_"
74
75
76class Term:
77 def __new__(cls, name, env, side=None, encoding=None):
78 klass = Constant if not isinstance(name, str) else cls
79 supr_new = super(Term, klass).__new__
80 return supr_new(klass)
81
82 is_local: bool
83
84 def __init__(self, name, env, side=None, encoding=None) -> None:
85 # name is a str for Term, but may be something else for subclasses
86 self._name = name
87 self.env = env
88 self.side = side
89 tname = str(name)
90 self.is_local = tname.startswith(LOCAL_TAG) or tname in DEFAULT_GLOBALS
91 self._value = self._resolve_name()
92 self.encoding = encoding
93
94 @property
95 def local_name(self) -> str:
96 return self.name.replace(LOCAL_TAG, "")
97
98 def __repr__(self) -> str:
99 return pprint_thing(self.name)
100
101 def __call__(self, *args, **kwargs):
102 return self.value
103
104 def evaluate(self, *args, **kwargs) -> Term:
105 return self
106
107 def _resolve_name(self):
108 local_name = str(self.local_name)
109 is_local = self.is_local
110 if local_name in self.env.scope and isinstance(
111 self.env.scope[local_name], type
112 ):
113 is_local = False
114
115 res = self.env.resolve(local_name, is_local=is_local)
116 self.update(res)
117
118 if hasattr(res, "ndim") and isinstance(res.ndim, int) and res.ndim > 2:
119 raise NotImplementedError(
120 "N-dimensional objects, where N > 2, are not supported with eval"
121 )
122 return res
123
124 def update(self, value) -> None:
125 """
126 search order for local (i.e., @variable) variables:
127
128 scope, key_variable
129 [('locals', 'local_name'),
130 ('globals', 'local_name'),
131 ('locals', 'key'),
132 ('globals', 'key')]
133 """
134 key = self.name
135
136 # if it's a variable name (otherwise a constant)
137 if isinstance(key, str):
138 self.env.swapkey(self.local_name, key, new_value=value)
139
140 self.value = value
141
142 @property
143 def is_scalar(self) -> bool:
144 return is_scalar(self._value)
145
146 @property
147 def type(self):
148 try:
149 # potentially very slow for large, mixed dtype frames
150 return self._value.values.dtype
151 except AttributeError:
152 try:
153 # ndarray
154 return self._value.dtype
155 except AttributeError:
156 # scalar
157 return type(self._value)
158
159 return_type = type
160
161 @property
162 def raw(self) -> str:
163 return f"{type(self).__name__}(name={self.name!r}, type={self.type})"
164
165 @property
166 def is_datetime(self) -> bool:
167 try:
168 t = self.type.type
169 except AttributeError:
170 t = self.type
171
172 return issubclass(t, (datetime, np.datetime64))
173
174 @property
175 def value(self):
176 return self._value
177
178 @value.setter
179 def value(self, new_value) -> None:
180 self._value = new_value
181
182 @property
183 def name(self):
184 return self._name
185
186 @property
187 def ndim(self) -> int:
188 return self._value.ndim
189
190
191class Constant(Term):
192 def _resolve_name(self):
193 return self._name
194
195 @property
196 def name(self):
197 return self.value
198
199 def __repr__(self) -> str:
200 # in python 2 str() of float
201 # can truncate shorter than repr()
202 return repr(self.name)
203
204
205_bool_op_map = {"not": "~", "and": "&", "or": "|"}
206
207
208class Op:
209 """
210 Hold an operator of arbitrary arity.
211 """
212
213 op: str
214
215 def __init__(self, op: str, operands: Iterable[Term | Op], encoding=None) -> None:
216 self.op = _bool_op_map.get(op, op)
217 self.operands = operands
218 self.encoding = encoding
219
220 def __iter__(self) -> Iterator:
221 return iter(self.operands)
222
223 def __repr__(self) -> str:
224 """
225 Print a generic n-ary operator and its operands using infix notation.
226 """
227 # recurse over the operands
228 parened = (f"({pprint_thing(opr)})" for opr in self.operands)
229 return pprint_thing(f" {self.op} ".join(parened))
230
231 @property
232 def return_type(self):
233 # clobber types to bool if the op is a boolean operator
234 if self.op in (CMP_OPS_SYMS + BOOL_OPS_SYMS):
235 return np.bool_
236 return result_type_many(*(term.type for term in com.flatten(self)))
237
238 @property
239 def has_invalid_return_type(self) -> bool:
240 types = self.operand_types
241 obj_dtype_set = frozenset([np.dtype("object")])
242 return self.return_type == object and types - obj_dtype_set
243
244 @property
245 def operand_types(self):
246 return frozenset(term.type for term in com.flatten(self))
247
248 @property
249 def is_scalar(self) -> bool:
250 return all(operand.is_scalar for operand in self.operands)
251
252 @property
253 def is_datetime(self) -> bool:
254 try:
255 t = self.return_type.type
256 except AttributeError:
257 t = self.return_type
258
259 return issubclass(t, (datetime, np.datetime64))
260
261
262def _in(x, y):
263 """
264 Compute the vectorized membership of ``x in y`` if possible, otherwise
265 use Python.
266 """
267 try:
268 return x.isin(y)
269 except AttributeError:
270 if is_list_like(x):
271 try:
272 return y.isin(x)
273 except AttributeError:
274 pass
275 return x in y
276
277
278def _not_in(x, y):
279 """
280 Compute the vectorized membership of ``x not in y`` if possible,
281 otherwise use Python.
282 """
283 try:
284 return ~x.isin(y)
285 except AttributeError:
286 if is_list_like(x):
287 try:
288 return ~y.isin(x)
289 except AttributeError:
290 pass
291 return x not in y
292
293
294CMP_OPS_SYMS = (">", "<", ">=", "<=", "==", "!=", "in", "not in")
295_cmp_ops_funcs = (
296 operator.gt,
297 operator.lt,
298 operator.ge,
299 operator.le,
300 operator.eq,
301 operator.ne,
302 _in,
303 _not_in,
304)
305_cmp_ops_dict = dict(zip(CMP_OPS_SYMS, _cmp_ops_funcs, strict=True))
306
307BOOL_OPS_SYMS = ("&", "|", "and", "or")
308_bool_ops_funcs = (operator.and_, operator.or_, operator.and_, operator.or_)
309_bool_ops_dict = dict(zip(BOOL_OPS_SYMS, _bool_ops_funcs, strict=True))
310
311ARITH_OPS_SYMS = ("+", "-", "*", "/", "**", "//", "%")
312_arith_ops_funcs = (
313 operator.add,
314 operator.sub,
315 operator.mul,
316 operator.truediv,
317 operator.pow,
318 operator.floordiv,
319 operator.mod,
320)
321_arith_ops_dict = dict(zip(ARITH_OPS_SYMS, _arith_ops_funcs, strict=True))
322
323_binary_ops_dict = {}
324
325for d in (_cmp_ops_dict, _bool_ops_dict, _arith_ops_dict):
326 _binary_ops_dict.update(d)
327
328
329def is_term(obj) -> bool:
330 return isinstance(obj, Term)
331
332
333class BinOp(Op):
334 """
335 Hold a binary operator and its operands.
336
337 Parameters
338 ----------
339 op : str
340 lhs : Term or Op
341 rhs : Term or Op
342 """
343
344 def __init__(self, op: str, lhs, rhs) -> None:
345 super().__init__(op, (lhs, rhs))
346 self.lhs = lhs
347 self.rhs = rhs
348
349 self._disallow_scalar_only_bool_ops()
350
351 self.convert_values()
352
353 try:
354 self.func = _binary_ops_dict[op]
355 except KeyError as err:
356 # has to be made a list for python3
357 keys = list(_binary_ops_dict.keys())
358 raise ValueError(
359 f"Invalid binary operator {op!r}, valid operators are {keys}"
360 ) from err
361
362 def __call__(self, env):
363 """
364 Recursively evaluate an expression in Python space.
365
366 Parameters
367 ----------
368 env : Scope
369
370 Returns
371 -------
372 object
373 The result of an evaluated expression.
374 """
375 # recurse over the left/right nodes
376 left = self.lhs(env)
377 right = self.rhs(env)
378
379 return self.func(left, right)
380
381 def evaluate(self, env, engine: str, parser, term_type, eval_in_python):
382 """
383 Evaluate a binary operation *before* being passed to the engine.
384
385 Parameters
386 ----------
387 env : Scope
388 engine : str
389 parser : str
390 term_type : type
391 eval_in_python : list
392
393 Returns
394 -------
395 term_type
396 The "pre-evaluated" expression as an instance of ``term_type``
397 """
398 if engine == "python":
399 res = self(env)
400 else:
401 # recurse over the left/right nodes
402
403 left = self.lhs.evaluate(
404 env,
405 engine=engine,
406 parser=parser,
407 term_type=term_type,
408 eval_in_python=eval_in_python,
409 )
410
411 right = self.rhs.evaluate(
412 env,
413 engine=engine,
414 parser=parser,
415 term_type=term_type,
416 eval_in_python=eval_in_python,
417 )
418
419 # base cases
420 if self.op in eval_in_python:
421 res = self.func(left.value, right.value)
422 else:
423 from pandas.core.computation.eval import eval
424
425 res = eval(self, local_dict=env, engine=engine, parser=parser)
426
427 name = env.add_tmp(res)
428 return term_type(name, env=env)
429
430 def convert_values(self) -> None:
431 """
432 Convert datetimes to a comparable value in an expression.
433 """
434
435 def stringify(value):
436 encoder: Callable
437 if self.encoding is not None:
438 encoder = partial(pprint_thing_encoded, encoding=self.encoding)
439 else:
440 encoder = pprint_thing
441 return encoder(value)
442
443 lhs, rhs = self.lhs, self.rhs
444
445 if is_term(lhs) and lhs.is_datetime and is_term(rhs) and rhs.is_scalar:
446 v = rhs.value
447 if isinstance(v, (int, float)):
448 v = stringify(v)
449 v = Timestamp(ensure_decoded(v))
450 if v.tz is not None:
451 v = v.tz_convert("UTC")
452 self.rhs.update(v)
453
454 if is_term(rhs) and rhs.is_datetime and is_term(lhs) and lhs.is_scalar:
455 v = lhs.value
456 if isinstance(v, (int, float)):
457 v = stringify(v)
458 v = Timestamp(ensure_decoded(v))
459 if v.tz is not None:
460 v = v.tz_convert("UTC")
461 self.lhs.update(v)
462
463 def _disallow_scalar_only_bool_ops(self) -> None:
464 rhs = self.rhs
465 lhs = self.lhs
466
467 # GH#24883 unwrap dtype if necessary to ensure we have a type object
468 rhs_rt = rhs.return_type
469 rhs_rt = getattr(rhs_rt, "type", rhs_rt)
470 lhs_rt = lhs.return_type
471 lhs_rt = getattr(lhs_rt, "type", lhs_rt)
472 if (
473 (lhs.is_scalar or rhs.is_scalar)
474 and self.op in _bool_ops_dict
475 and (
476 not (
477 issubclass(rhs_rt, (bool, np.bool_))
478 and issubclass(lhs_rt, (bool, np.bool_))
479 )
480 )
481 ):
482 raise NotImplementedError("cannot evaluate scalar only bool ops")
483
484
485UNARY_OPS_SYMS = ("+", "-", "~", "not")
486_unary_ops_funcs = (operator.pos, operator.neg, operator.invert, operator.invert)
487_unary_ops_dict = dict(zip(UNARY_OPS_SYMS, _unary_ops_funcs, strict=True))
488
489
490class UnaryOp(Op):
491 """
492 Hold a unary operator and its operands.
493
494 Parameters
495 ----------
496 op : str
497 The token used to represent the operator.
498 operand : Term or Op
499 The Term or Op operand to the operator.
500
501 Raises
502 ------
503 ValueError
504 * If no function associated with the passed operator token is found.
505 """
506
507 def __init__(self, op: Literal["+", "-", "~", "not"], operand) -> None:
508 super().__init__(op, (operand,))
509 self.operand = operand
510
511 try:
512 self.func = _unary_ops_dict[op]
513 except KeyError as err:
514 raise ValueError(
515 f"Invalid unary operator {op!r}, valid operators are {UNARY_OPS_SYMS}"
516 ) from err
517
518 def __call__(self, env) -> MathCall:
519 operand = self.operand(env)
520 # error: Cannot call function of unknown type
521 return self.func(operand) # type: ignore[operator]
522
523 def __repr__(self) -> str:
524 return pprint_thing(f"{self.op}({self.operand})")
525
526 @property
527 def return_type(self) -> np.dtype:
528 operand = self.operand
529 if operand.return_type == np.dtype("bool"):
530 return np.dtype("bool")
531 if isinstance(operand, Op) and (
532 operand.op in _cmp_ops_dict or operand.op in _bool_ops_dict
533 ):
534 return np.dtype("bool")
535 return np.dtype("int")
536
537
538class MathCall(Op):
539 def __init__(self, func, args) -> None:
540 super().__init__(func.name, args)
541 self.func = func
542
543 def __call__(self, env):
544 # error: "Op" not callable
545 operands = [op(env) for op in self.operands] # type: ignore[operator]
546 return self.func.func(*operands)
547
548 def __repr__(self) -> str:
549 operands = map(str, self.operands)
550 return pprint_thing(f"{self.op}({','.join(operands)})")
551
552
553class FuncNode:
554 def __init__(self, name: str) -> None:
555 if name not in MATHOPS:
556 raise ValueError(f'"{name}" is not a supported function')
557 self.name = name
558 self.func = getattr(np, name)
559
560 def __call__(self, *args) -> MathCall:
561 return MathCall(self, args)