1"""manage PyTables query interface via Expressions"""
2
3from __future__ import annotations
4
5import ast
6from decimal import (
7 Decimal,
8 InvalidOperation,
9)
10from functools import partial
11from typing import (
12 TYPE_CHECKING,
13 Any,
14 ClassVar,
15 Self,
16 cast,
17)
18
19import numpy as np
20
21from pandas._libs import lib
22from pandas._libs.tslibs import (
23 Timedelta,
24 Timestamp,
25)
26from pandas.errors import UndefinedVariableError
27
28from pandas.core.dtypes.common import is_list_like
29
30import pandas.core.common as com
31from pandas.core.computation import (
32 expr,
33 ops,
34 scope as _scope,
35)
36from pandas.core.computation.common import ensure_decoded
37from pandas.core.computation.expr import BaseExprVisitor
38from pandas.core.computation.ops import is_term
39from pandas.core.construction import extract_array
40from pandas.core.indexes.base import Index
41
42from pandas.io.formats.printing import (
43 pprint_thing,
44 pprint_thing_encoded,
45)
46
47if TYPE_CHECKING:
48 from pandas._typing import (
49 TimeUnit,
50 npt,
51 )
52
53
54class PyTablesScope(_scope.Scope):
55 __slots__ = ("queryables",)
56
57 queryables: dict[str, Any]
58
59 def __init__(
60 self,
61 level: int,
62 global_dict=None,
63 local_dict=None,
64 queryables: dict[str, Any] | None = None,
65 ) -> None:
66 super().__init__(level + 1, global_dict=global_dict, local_dict=local_dict)
67 self.queryables = queryables or {}
68
69
70class Term(ops.Term):
71 env: PyTablesScope
72
73 def __new__(cls, name, env, side=None, encoding=None):
74 if isinstance(name, str):
75 klass = cls
76 else:
77 klass = Constant
78 return object.__new__(klass)
79
80 def __init__(self, name, env: PyTablesScope, side=None, encoding=None) -> None:
81 super().__init__(name, env, side=side, encoding=encoding)
82
83 def _resolve_name(self):
84 # must be a queryables
85 if self.side == "left":
86 # Note: The behavior of __new__ ensures that self.name is a str here
87 if self.name not in self.env.queryables:
88 raise NameError(f"name {self.name!r} is not defined")
89 return self.name
90
91 # resolve the rhs (and allow it to be None)
92 try:
93 return self.env.resolve(self.name, is_local=False)
94 except UndefinedVariableError:
95 return self.name
96
97 # read-only property overwriting read/write property
98 @property # type: ignore[misc]
99 def value(self):
100 return self._value
101
102
103class Constant(Term):
104 def __init__(self, name, env: PyTablesScope, side=None, encoding=None) -> None:
105 assert isinstance(env, PyTablesScope), type(env)
106 super().__init__(name, env, side=side, encoding=encoding)
107
108 def _resolve_name(self):
109 return self._name
110
111
112class BinOp(ops.BinOp):
113 _max_selectors = 31
114
115 op: str
116 queryables: dict[str, Any]
117 condition: str | None
118
119 def __init__(self, op: str, lhs, rhs, queryables: dict[str, Any], encoding) -> None:
120 super().__init__(op, lhs, rhs)
121 self.queryables = queryables
122 self.encoding = encoding
123 self.condition = None
124
125 def _disallow_scalar_only_bool_ops(self) -> None:
126 pass
127
128 def prune(self, klass):
129 def pr(left, right):
130 """create and return a new specialized BinOp from myself"""
131 if left is None:
132 return right
133 elif right is None:
134 return left
135
136 k = klass
137 if isinstance(left, ConditionBinOp):
138 if isinstance(right, ConditionBinOp):
139 k = JointConditionBinOp
140 elif isinstance(left, k):
141 return left
142 elif isinstance(right, k):
143 return right
144
145 elif isinstance(left, FilterBinOp):
146 if isinstance(right, FilterBinOp):
147 k = JointFilterBinOp
148 elif isinstance(left, k):
149 return left
150 elif isinstance(right, k):
151 return right
152
153 return k(
154 self.op, left, right, queryables=self.queryables, encoding=self.encoding
155 ).evaluate()
156
157 left, right = self.lhs, self.rhs
158
159 if is_term(left) and is_term(right):
160 res = pr(left.value, right.value)
161 elif not is_term(left) and is_term(right):
162 res = pr(left.prune(klass), right.value)
163 elif is_term(left) and not is_term(right):
164 res = pr(left.value, right.prune(klass))
165 elif not (is_term(left) or is_term(right)):
166 res = pr(left.prune(klass), right.prune(klass))
167
168 return res
169
170 def conform(self, rhs):
171 """inplace conform rhs"""
172 if not is_list_like(rhs):
173 rhs = [rhs]
174 if isinstance(rhs, np.ndarray):
175 rhs = rhs.ravel()
176 return rhs
177
178 @property
179 def is_valid(self) -> bool:
180 """return True if this is a valid field"""
181 return self.lhs in self.queryables
182
183 @property
184 def is_in_table(self) -> bool:
185 """
186 return True if this is a valid column name for generation (e.g. an
187 actual column in the table)
188 """
189 return self.queryables.get(self.lhs) is not None
190
191 @property
192 def kind(self):
193 """the kind of my field"""
194 return getattr(self.queryables.get(self.lhs), "kind", None)
195
196 @property
197 def meta(self):
198 """the meta of my field"""
199 return getattr(self.queryables.get(self.lhs), "meta", None)
200
201 @property
202 def metadata(self):
203 """the metadata of my field"""
204 return getattr(self.queryables.get(self.lhs), "metadata", None)
205
206 def generate(self, v) -> str:
207 """create and return the op string for this TermValue"""
208 val = v.tostring(self.encoding)
209 return f"({self.lhs} {self.op} {val})"
210
211 def convert_value(self, conv_val) -> TermValue:
212 """
213 convert the expression that is in the term to something that is
214 accepted by pytables
215 """
216
217 def stringify(value):
218 if self.encoding is not None:
219 return pprint_thing_encoded(value, encoding=self.encoding)
220 return pprint_thing(value)
221
222 kind = ensure_decoded(self.kind)
223 meta = ensure_decoded(self.meta)
224 if kind == "datetime" or (kind and kind.startswith("datetime64")):
225 if isinstance(conv_val, (int, float)):
226 conv_val = stringify(conv_val)
227 conv_val = ensure_decoded(conv_val)
228 unit: TimeUnit = "ns"
229 if "[" in kind:
230 unit = cast("TimeUnit", kind.split("[")[-1][:-1])
231 conv_val = Timestamp(conv_val).as_unit(unit)
232 if conv_val.tz is not None:
233 conv_val = conv_val.tz_convert("UTC")
234 return TermValue(conv_val, conv_val._value, kind)
235 elif kind.startswith("timedelta"):
236 unit = "ns"
237 if "[" in kind:
238 unit = cast("TimeUnit", kind.split("[")[-1][:-1])
239 if isinstance(conv_val, str):
240 conv_val = Timedelta(conv_val)
241 elif lib.is_integer(conv_val) or lib.is_float(conv_val):
242 conv_val = Timedelta(conv_val, unit="s")
243 else:
244 conv_val = Timedelta(conv_val)
245 conv_val = conv_val.as_unit(unit)._value
246 return TermValue(int(conv_val), conv_val, kind)
247
248 elif meta == "category":
249 metadata = extract_array(self.metadata, extract_numpy=True)
250 result: npt.NDArray[np.intp] | np.intp | int
251 if conv_val not in metadata:
252 result = -1
253 else:
254 # Find the index of the first match of conv_val in metadata
255 result = np.flatnonzero(metadata == conv_val)[0]
256 return TermValue(result, result, "integer")
257 elif kind == "integer":
258 try:
259 v_dec = Decimal(conv_val)
260 except InvalidOperation:
261 # GH 54186
262 # convert v to float to raise float's ValueError
263 float(conv_val)
264 else:
265 conv_val = int(v_dec.to_integral_exact(rounding="ROUND_HALF_EVEN"))
266 return TermValue(conv_val, conv_val, kind)
267 elif kind == "float":
268 conv_val = float(conv_val)
269 return TermValue(conv_val, conv_val, kind)
270 elif kind == "bool":
271 if isinstance(conv_val, str):
272 conv_val = conv_val.strip().lower() not in [
273 "false",
274 "f",
275 "no",
276 "n",
277 "none",
278 "0",
279 "[]",
280 "{}",
281 "",
282 ]
283 else:
284 conv_val = bool(conv_val)
285 return TermValue(conv_val, conv_val, kind)
286 elif isinstance(conv_val, str):
287 # string quoting
288 return TermValue(conv_val, stringify(conv_val), "string")
289 else:
290 raise TypeError(
291 f"Cannot compare {conv_val} of type {type(conv_val)} to {kind} column"
292 )
293
294 def convert_values(self) -> None:
295 pass
296
297
298class FilterBinOp(BinOp):
299 filter: tuple[Any, Any, Index] | None = None
300
301 def __repr__(self) -> str:
302 if self.filter is None:
303 return "Filter: Not Initialized"
304 return pprint_thing(f"[Filter : [{self.filter[0]}] -> [{self.filter[1]}]")
305
306 def invert(self) -> Self:
307 """invert the filter"""
308 if self.filter is not None:
309 self.filter = (
310 self.filter[0],
311 self.generate_filter_op(invert=True),
312 self.filter[2],
313 )
314 return self
315
316 def format(self):
317 """return the actual filter format"""
318 return [self.filter]
319
320 # error: Signature of "evaluate" incompatible with supertype "BinOp"
321 def evaluate(self) -> Self | None: # type: ignore[override]
322 if not self.is_valid:
323 raise ValueError(f"query term is not valid [{self}]")
324
325 rhs = self.conform(self.rhs)
326 values = list(rhs)
327
328 if self.is_in_table:
329 # if too many values to create the expression, use a filter instead
330 if self.op in ["==", "!="] and len(values) > self._max_selectors:
331 filter_op = self.generate_filter_op()
332 self.filter = (self.lhs, filter_op, Index(values))
333
334 return self
335 return None
336
337 # equality conditions
338 if self.op in ["==", "!="]:
339 filter_op = self.generate_filter_op()
340 self.filter = (self.lhs, filter_op, Index(values))
341
342 else:
343 raise TypeError(
344 f"passing a filterable condition to a non-table indexer [{self}]"
345 )
346
347 return self
348
349 def generate_filter_op(self, invert: bool = False):
350 if (self.op == "!=" and not invert) or (self.op == "==" and invert):
351 return lambda axis, vals: ~axis.isin(vals)
352 else:
353 return lambda axis, vals: axis.isin(vals)
354
355
356class JointFilterBinOp(FilterBinOp):
357 def format(self):
358 raise NotImplementedError("unable to collapse Joint Filters")
359
360 # error: Signature of "evaluate" incompatible with supertype "BinOp"
361 def evaluate(self) -> Self: # type: ignore[override]
362 return self
363
364
365class ConditionBinOp(BinOp):
366 def __repr__(self) -> str:
367 return pprint_thing(f"[Condition : [{self.condition}]]")
368
369 def invert(self):
370 """invert the condition"""
371 # if self.condition is not None:
372 # self.condition = "~(%s)" % self.condition
373 # return self
374 raise NotImplementedError(
375 "cannot use an invert condition when passing to numexpr"
376 )
377
378 def format(self):
379 """return the actual ne format"""
380 return self.condition
381
382 # error: Signature of "evaluate" incompatible with supertype "BinOp"
383 def evaluate(self) -> Self | None: # type: ignore[override]
384 if not self.is_valid:
385 raise ValueError(f"query term is not valid [{self}]")
386
387 # convert values if we are in the table
388 if not self.is_in_table:
389 return None
390
391 rhs = self.conform(self.rhs)
392 values = [self.convert_value(v) for v in rhs]
393
394 # equality conditions
395 if self.op in ["==", "!="]:
396 # too many values to create the expression?
397 if len(values) <= self._max_selectors:
398 vs = [self.generate(v) for v in values]
399 self.condition = f"({' | '.join(vs)})"
400
401 # use a filter after reading
402 else:
403 return None
404 else:
405 self.condition = self.generate(values[0])
406
407 return self
408
409
410class JointConditionBinOp(ConditionBinOp):
411 # error: Signature of "evaluate" incompatible with supertype "BinOp"
412 def evaluate(self) -> Self: # type: ignore[override]
413 self.condition = f"({self.lhs.condition} {self.op} {self.rhs.condition})"
414 return self
415
416
417class UnaryOp(ops.UnaryOp):
418 def prune(self, klass):
419 if self.op != "~":
420 raise NotImplementedError("UnaryOp only support invert type ops")
421
422 operand = self.operand
423 operand = operand.prune(klass)
424
425 if operand is not None and (
426 (issubclass(klass, ConditionBinOp) and operand.condition is not None)
427 or (
428 not issubclass(klass, ConditionBinOp)
429 and issubclass(klass, FilterBinOp)
430 and operand.filter is not None
431 )
432 ):
433 return operand.invert()
434 return None
435
436
437class PyTablesExprVisitor(BaseExprVisitor):
438 const_type: ClassVar[type[ops.Term]] = Constant
439 term_type: ClassVar[type[Term]] = Term
440
441 def __init__(self, env, engine, parser, **kwargs) -> None:
442 super().__init__(env, engine, parser)
443 for bin_op in self.binary_ops:
444 bin_node = self.binary_op_nodes_map[bin_op]
445 setattr(
446 self,
447 f"visit_{bin_node}",
448 lambda node, bin_op=bin_op: partial(BinOp, bin_op, **kwargs),
449 )
450
451 def visit_UnaryOp(self, node, **kwargs) -> ops.Term | UnaryOp | None:
452 if isinstance(node.op, (ast.Not, ast.Invert)):
453 return UnaryOp("~", self.visit(node.operand))
454 elif isinstance(node.op, ast.USub):
455 return self.const_type(-self.visit(node.operand).value, self.env)
456 elif isinstance(node.op, ast.UAdd):
457 raise NotImplementedError("Unary addition not supported")
458 # TODO: return None might never be reached
459 return None
460
461 def visit_Index(self, node, **kwargs):
462 return self.visit(node.value).value
463
464 def visit_Assign(self, node, **kwargs):
465 cmpr = ast.Compare(
466 ops=[ast.Eq()], left=node.targets[0], comparators=[node.value]
467 )
468 return self.visit(cmpr)
469
470 def visit_Subscript(self, node, **kwargs) -> ops.Term:
471 # only allow simple subscripts
472
473 value = self.visit(node.value)
474 slobj = self.visit(node.slice)
475 try:
476 value = value.value
477 except AttributeError:
478 pass
479
480 if isinstance(slobj, Term):
481 # In py39 np.ndarray lookups with Term containing int raise
482 slobj = slobj.value
483
484 try:
485 return self.const_type(value[slobj], self.env)
486 except TypeError as err:
487 raise ValueError(f"cannot subscript {value!r} with {slobj!r}") from err
488
489 def visit_Attribute(self, node, **kwargs):
490 attr = node.attr
491 value = node.value
492
493 ctx = type(node.ctx)
494 if ctx == ast.Load:
495 # resolve the value
496 resolved = self.visit(value)
497
498 # try to get the value to see if we are another expression
499 try:
500 resolved = resolved.value
501 except AttributeError:
502 pass
503
504 try:
505 return self.term_type(getattr(resolved, attr), self.env)
506 except AttributeError:
507 # something like datetime.datetime where scope is overridden
508 if isinstance(value, ast.Name) and value.id == attr:
509 return resolved
510
511 raise ValueError(f"Invalid Attribute context {ctx.__name__}")
512
513 def translate_In(self, op):
514 return ast.Eq() if isinstance(op, ast.In) else op
515
516 def _rewrite_membership_op(self, node, left, right):
517 return self.visit(node.op), node.op, left, right
518
519
520def _validate_where(w):
521 """
522 Validate that the where statement is of the right type.
523
524 The type may either be String, Expr, or list-like of Exprs.
525
526 Parameters
527 ----------
528 w : String term expression, Expr, or list-like of Exprs.
529
530 Returns
531 -------
532 where : The original where clause if the check was successful.
533
534 Raises
535 ------
536 TypeError : An invalid data type was passed in for w (e.g. dict).
537 """
538 if not (isinstance(w, (PyTablesExpr, str)) or is_list_like(w)):
539 raise TypeError(
540 "where must be passed as a string, PyTablesExpr, "
541 "or list-like of PyTablesExpr"
542 )
543
544 return w
545
546
547class PyTablesExpr(expr.Expr):
548 """
549 Hold a pytables-like expression, comprised of possibly multiple 'terms'.
550
551 Parameters
552 ----------
553 where : string term expression, PyTablesExpr, or list-like of PyTablesExprs
554 queryables : a "kinds" map (dict of column name -> kind), or None if column
555 is non-indexable
556 encoding : an encoding that will encode the query terms
557
558 Returns
559 -------
560 a PyTablesExpr object
561
562 Examples
563 --------
564 'index>=date'
565 "columns=['A', 'D']"
566 'columns=A'
567 'columns==A'
568 "~(columns=['A','B'])"
569 'index>df.index[3] & string="bar"'
570 '(index>df.index[3] & index<=df.index[6]) | string="bar"'
571 "ts>=Timestamp('2012-02-01')"
572 "major_axis>=20130101"
573 """
574
575 _visitor: PyTablesExprVisitor | None
576 env: PyTablesScope
577 expr: str
578
579 def __init__(
580 self,
581 where,
582 queryables: dict[str, Any] | None = None,
583 encoding=None,
584 scope_level: int = 0,
585 ) -> None:
586 where = _validate_where(where)
587
588 self.encoding = encoding
589 self.condition = None
590 self.filter = None
591 self.terms = None
592 self._visitor = None
593
594 # capture the environment if needed
595 local_dict: _scope.DeepChainMap[Any, Any] | None = None
596
597 if isinstance(where, PyTablesExpr):
598 local_dict = where.env.scope
599 _where = where.expr
600
601 elif is_list_like(where):
602 where = list(where)
603 for idx, w in enumerate(where):
604 if isinstance(w, PyTablesExpr):
605 local_dict = w.env.scope
606 else:
607 where[idx] = _validate_where(w)
608 _where = " & ".join([f"({w})" for w in com.flatten(where)])
609 else:
610 # _validate_where ensures we otherwise have a string
611 _where = where
612
613 self.expr = _where
614 self.env = PyTablesScope(scope_level + 1, local_dict=local_dict)
615
616 if queryables is not None and isinstance(self.expr, str):
617 self.env.queryables.update(queryables)
618 self._visitor = PyTablesExprVisitor(
619 self.env,
620 queryables=queryables,
621 parser="pytables",
622 engine="pytables",
623 encoding=encoding,
624 )
625 self.terms = self.parse()
626
627 def __repr__(self) -> str:
628 if self.terms is not None:
629 return pprint_thing(self.terms)
630 return pprint_thing(self.expr)
631
632 def evaluate(self):
633 """create and return the numexpr condition and filter"""
634 try:
635 self.condition = self.terms.prune(ConditionBinOp)
636 except AttributeError as err:
637 raise ValueError(
638 f"cannot process expression [{self.expr}], [{self}] "
639 "is not a valid condition"
640 ) from err
641 try:
642 self.filter = self.terms.prune(FilterBinOp)
643 except AttributeError as err:
644 raise ValueError(
645 f"cannot process expression [{self.expr}], [{self}] "
646 "is not a valid filter"
647 ) from err
648
649 return self.condition, self.filter
650
651
652class TermValue:
653 """hold a term value the we use to construct a condition/filter"""
654
655 def __init__(self, value, converted, kind: str) -> None:
656 assert isinstance(kind, str), kind
657 self.value = value
658 self.converted = converted
659 self.kind = kind
660
661 def tostring(self, encoding) -> str:
662 """quote the string if not encoded else encode and return"""
663 if self.kind == "string":
664 if encoding is not None:
665 return str(self.converted)
666 return f'"{self.converted}"'
667 elif self.kind == "float":
668 # python 2 str(float) is not always
669 # round-trippable so use repr()
670 return repr(self.converted)
671 return str(self.converted)
672
673
674def maybe_expression(s) -> bool:
675 """loose checking if s is a pytables-acceptable expression"""
676 if not isinstance(s, str):
677 return False
678 operations = PyTablesExprVisitor.binary_ops + PyTablesExprVisitor.unary_ops + ("=",)
679
680 # make sure we have an op at least
681 return any(op in s for op in operations)