ransacklib 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ransack/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .exceptions import EvaluationError, ParseError, RansackError, ShapeError
2
+ from .parser import Parser
3
+ from .transformer import Filter, get_values
4
+
5
+ __version__ = "0.1.10"
6
+
7
+ __all__ = (
8
+ "get_values",
9
+ "Parser",
10
+ "Filter",
11
+ "RansackError",
12
+ "ParseError",
13
+ "ShapeError",
14
+ "EvaluationError",
15
+ )
ransack/exceptions.py ADDED
@@ -0,0 +1,118 @@
1
+ """Custom exceptions for Ransack query parsing, evaluation, and shape validation."""
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class RansackError(Exception):
7
+ """Base exception for all query-related errors."""
8
+
9
+
10
+ class PositionInfoMixin:
11
+ """Mixin class that adds source position metadata to exceptions."""
12
+
13
+ def __init__(
14
+ self,
15
+ line: int,
16
+ column: int,
17
+ context: Optional[str] = None,
18
+ start_pos: Optional[int] = None,
19
+ end_pos: Optional[int] = None,
20
+ end_line: Optional[int] = None,
21
+ end_column: Optional[int] = None,
22
+ *args,
23
+ **kwargs,
24
+ ):
25
+ self.line = line
26
+ self.column = column
27
+ self.context = context
28
+ self.start_pos = start_pos
29
+ self.end_pos = end_pos
30
+ self.end_line = end_line
31
+ self.end_column = end_column
32
+ super().__init__(*args, **kwargs)
33
+
34
+
35
+ class ParseError(PositionInfoMixin, RansackError):
36
+ """Raised when the input query cannot be parsed."""
37
+
38
+ def __str__(self):
39
+ return f"Syntax error at line {self.line}, column {self.column}.\n\n{self.context}" # noqa
40
+
41
+
42
+ class ShapeError(PositionInfoMixin, RansackError):
43
+ """Raised when a query has an invalid shape or structure."""
44
+
45
+ def __init__(self, msg: str, **kwargs):
46
+ self.msg = msg
47
+ super().__init__(**kwargs)
48
+
49
+ def __str__(self):
50
+ return f"Error at line {self.line}, column {self.column}.\n\n{self.msg}\n\n{self.context}" # noqa
51
+
52
+
53
+ class EvaluationError(PositionInfoMixin, RansackError):
54
+ """Raised when a query fails during evaluation."""
55
+
56
+ def __init__(self, msg: str, **kwargs):
57
+ self.msg = msg
58
+ super().__init__(**kwargs)
59
+
60
+ def __str__(self):
61
+ return f"Error at line {self.line}, column {self.column}.\n\n{self.msg}"
62
+
63
+
64
+ class OperatorNotFoundError(RansackError):
65
+ """Exception raised when an operator is not found for the provided types."""
66
+
67
+ def __init__(
68
+ self,
69
+ operator: str,
70
+ types: tuple[type | str, type | str],
71
+ values: tuple[Any, Any] | None = None,
72
+ ):
73
+ """
74
+ Initialize the exception with operator, operand types, and optional values.
75
+
76
+ Args:
77
+ operator: The operator that was attempted.
78
+ types: The types of the operands.
79
+ values: The values of the operands. Defaults to None.
80
+ """
81
+ self.operator = operator
82
+ self.types = types
83
+ self.values = values
84
+ message = f"Operator '{operator}' not found for types {types}"
85
+ if values:
86
+ message += f" with values {values}"
87
+ super().__init__(message)
88
+
89
+
90
+ def add_caret_to_context(
91
+ context: str, line: int, column: int, original_data: str, context_start_pos: int
92
+ ) -> str:
93
+ """
94
+ Inserts a caret (^) under the character at (line, column) relative
95
+ to the full input. `context` is a slice of the full input string.
96
+ `line` and `column` are from the parser (1-based).
97
+ """
98
+ # Recalculate absolute position
99
+ lines_up_to_error = original_data.splitlines()[0 : line - 1]
100
+ absolute_error_pos = (
101
+ sum(len(lines) + 1 for lines in lines_up_to_error) + column - 1
102
+ ) # +1 for newline
103
+
104
+ caret_pos_in_context = absolute_error_pos - context_start_pos
105
+
106
+ # Sanity check
107
+ if caret_pos_in_context < 0 or caret_pos_in_context > len(context):
108
+ return context # fallback, avoid crashing
109
+
110
+ # Find line offset in context
111
+ line_start = context.rfind("\n", 0, caret_pos_in_context) + 1
112
+ line_end = context.find("\n", caret_pos_in_context)
113
+ if line_end == -1:
114
+ line_end = len(context)
115
+
116
+ caret_line = " " * (caret_pos_in_context - line_start) + "^"
117
+
118
+ return context[:line_end] + "\n" + caret_line + context[line_end:]
ransack/function.py ADDED
@@ -0,0 +1,38 @@
1
+ """
2
+ function.py - Defines built-in functions usable in query expressions.
3
+
4
+ This module provides a set of predefined utility functions that can be invoked from
5
+ within query expressions. These functions enable common operations such as determining
6
+ the length of a value or obtaining the current time.
7
+
8
+ Functions:
9
+ - length(x): Returns the length of a string, list, or IP range/network.
10
+ - now(): Returns the current UTC datetime.
11
+
12
+ These functions are registered in the `predefined_functions` dictionary and are
13
+ automatically available during query evaluation.
14
+
15
+ Example usage in query:
16
+ length(some_field) > 3 or now() > 2024-01-01T00:00:00Z
17
+ """
18
+
19
+ import datetime
20
+
21
+ from ipranges import IP4, IP6, IP4Net, IP4Range, IP6Net, IP6Range
22
+
23
+
24
+ def length(x):
25
+ if isinstance(x, (list, str, IP4, IP4Net, IP4Range, IP6, IP6Net, IP6Range)):
26
+ return len(x)
27
+ raise TypeError(f"Function length is not defined for value {x}")
28
+
29
+
30
+ def now():
31
+ return datetime.datetime.now(datetime.timezone.utc)
32
+
33
+
34
+ predefined_functions = {
35
+ "length": length,
36
+ "len": length,
37
+ "now": now,
38
+ }
ransack/operator.py ADDED
@@ -0,0 +1,500 @@
1
+ import re
2
+ from collections.abc import MutableSequence
3
+ from datetime import datetime, timedelta, timezone
4
+ from functools import partial
5
+ from numbers import Number
6
+ from operator import add, eq, ge, gt, le, lt, mod, mul, sub, truediv
7
+ from typing import Any, Callable, Union
8
+
9
+ from ipranges import IP4, IP6, IP4Net, IP4Range, IP6Net, IP6Range
10
+
11
+ from .exceptions import OperatorNotFoundError
12
+
13
+ IP = Union[IP4, IP4Net, IP4Range, IP6, IP6Net, IP6Range]
14
+ Operand = type | str
15
+
16
+ commutative_operators = {"+", "*"}
17
+
18
+
19
+ def _op_scalar_range(op: str, scalar, _range: tuple) -> tuple:
20
+ """
21
+ Perform a binary operation between a scalar and a range (tuple).
22
+
23
+ Args:
24
+ op: The operator to apply.
25
+ scalar: The scalar operand.
26
+ _range: The range operand as a tuple of two values.
27
+
28
+ Returns:
29
+ A tuple containing the result of the operation applied to
30
+ each end of the range.
31
+ """
32
+ start, end = _range
33
+ return (binary_operation(op, scalar, start), binary_operation(op, scalar, end))
34
+
35
+
36
+ def _op_range_scalar(op: str, _range: tuple, scalar) -> tuple:
37
+ """
38
+ Perform a binary operation between a range (tuple) and a scalar.
39
+
40
+ Args:
41
+ op: The operator to apply.
42
+ _range: The range operand as a tuple of two values.
43
+ scalar: The scalar operand.
44
+
45
+ Returns:
46
+ A tuple containing the result of the operation applied to
47
+ each end of the range.
48
+ """
49
+ start, end = _range
50
+ return (binary_operation(op, start, scalar), binary_operation(op, end, scalar))
51
+
52
+
53
+ def _op_scalar_list(op: str, scalar, t: MutableSequence) -> list:
54
+ """
55
+ Perform a binary operation between a scalar and each element in a list.
56
+
57
+ Args:
58
+ op: The operator to apply.
59
+ scalar: The scalar operand.
60
+ t: The list of operands.
61
+
62
+ Returns:
63
+ A list of results from the operation.
64
+ """
65
+ return [binary_operation(op, scalar, elem) for elem in t]
66
+
67
+
68
+ def _op_list_scalar(op: str, t: MutableSequence, scalar: Any) -> list:
69
+ """
70
+ Perform a binary operation between each element in a list and a scalar.
71
+
72
+ Args:
73
+ op: The operator to apply.
74
+ t: The list of operands.
75
+ scalar: The scalar operand.
76
+
77
+ Returns:
78
+ A list of results from the operation.
79
+ """
80
+ return [binary_operation(op, elem, scalar) for elem in t]
81
+
82
+
83
+ def _comp_scalar_range(op: str, scalar, _range: tuple) -> bool:
84
+ """
85
+ Compare a scalar with a range (tuple) using the specified operator.
86
+
87
+ Args:
88
+ op: The comparison operator.
89
+ scalar: The scalar operand.
90
+ _range: The range operand as a tuple of two values.
91
+
92
+ Returns:
93
+ The result of the comparison.
94
+ """
95
+ if op == "=":
96
+ return _in_scalar_range(scalar, _range)
97
+ start, end = _range
98
+ return binary_operation(op, scalar, start) or binary_operation(op, scalar, end)
99
+
100
+
101
+ def _comp_range_scalar(op: str, _range: tuple, scalar) -> bool:
102
+ """
103
+ Compare a range (tuple) with a scalar using the specified operator.
104
+
105
+ Args:
106
+ op: The comparison operator.
107
+ _range: The range operand as a tuple of two values.
108
+ scalar: The scalar operand.
109
+
110
+ Returns:
111
+ The result of the comparison.
112
+ """
113
+ if op == "=":
114
+ return _in_scalar_range(scalar, _range)
115
+ start, end = _range
116
+ return binary_operation(op, start, scalar) or binary_operation(op, end, scalar)
117
+
118
+
119
+ def _comp_scalar_list(op: str, scalar: Any, t: MutableSequence) -> bool:
120
+ """
121
+ Compare a scalar with a list using the specified operator.
122
+
123
+ Args:
124
+ op: The comparison operator.
125
+ scalar: The scalar operand.
126
+ t: The list of operands.
127
+
128
+ Returns:
129
+ True if the comparison is satisfied for any element in the list,
130
+ otherwise False.
131
+ """
132
+ return any(map(partial(binary_operation, op, scalar), t))
133
+
134
+
135
+ def _comp_list_scalar(op: str, t: MutableSequence, scalar: Any) -> bool:
136
+ """
137
+ Compare each element of a list with a scalar using the specified operator.
138
+
139
+ Args:
140
+ op: The comparison operator.
141
+ t: The list of operands.
142
+ scalar: The scalar operand.
143
+
144
+ Returns:
145
+ True if the comparison is satisfied for any element in the list,
146
+ otherwise False.
147
+ """
148
+ return any(binary_operation(op, x, scalar) for x in t)
149
+
150
+
151
+ def _comp_list_list(op: str, t1: MutableSequence, t2: MutableSequence) -> bool:
152
+ """
153
+ Compare two lists using the specified operator.
154
+
155
+ Args:
156
+ op: The comparison operator.
157
+ t1: The first list of operands.
158
+ t2: The second list of operands.
159
+
160
+ Returns:
161
+ True if the comparison is satisfied for any pair of elements,
162
+ otherwise False.
163
+ """
164
+ return any(_comp_scalar_list(op, elem, t2) for elem in t1)
165
+
166
+
167
+ def _comp_ip_ip(op: str, ip1: IP, ip2: IP) -> bool:
168
+ """
169
+ Compare two ipranges objects using the specified operator.
170
+
171
+ Args:
172
+ op: The comparison operator.
173
+ ip1: The first IP object.
174
+ ip2: The second IP object.
175
+
176
+ Returns:
177
+ The result of the comparison.
178
+
179
+ Raises:
180
+ OperatorNotFoundError: If the operator is not recognized for ipranges types.
181
+ """
182
+ match op:
183
+ case "<":
184
+ return ip1.low() < ip2.high()
185
+ case ">":
186
+ return ip1.high() > ip2.low()
187
+ case ">=":
188
+ return ip1.high() >= ip2.low()
189
+ case "<=":
190
+ return ip1.low() <= ip2.high()
191
+ case "=":
192
+ return ip1.low() == ip2.low() and ip1.high() == ip2.high()
193
+ case _:
194
+ raise OperatorNotFoundError(op, ("ip", "ip"), (ip1, ip2))
195
+
196
+
197
+ def _concat(a, b) -> str | MutableSequence:
198
+ """
199
+ Concatenate two objects, either strings or lists.
200
+
201
+ Args:
202
+ a: The first object, either a string or a list.
203
+ b: The second object, either a string or a list.
204
+
205
+ Returns:
206
+ The concatenated result.
207
+ """
208
+ return a + b
209
+
210
+
211
+ def _in_scalar_list(left, right: MutableSequence) -> bool:
212
+ """
213
+ Check if a scalar value is contained within a list or iterable.
214
+
215
+ Args:
216
+ left: The scalar value to check for membership.
217
+ right: The list or iterable to search within.
218
+
219
+ Returns:
220
+ True if the scalar value is found in the list or iterable,
221
+ otherwise False.
222
+
223
+ Notes:
224
+ - For each element in `right`, if it belongs to specific iterable
225
+ types (`list`, `tuple`, `IP4Net`, `IP4Range`, `IP6Range`, `IP6Net`),
226
+ the membership check is performed using the `binary_operation("in")`.
227
+ - For other types, the comparison is done using equality (`==`).
228
+ """
229
+ iterable = (MutableSequence, tuple, IP4Net, IP4Range, IP6Range, IP6Net)
230
+ return any(
231
+ binary_operation("in", left, x) if isinstance(x, iterable) else left == x
232
+ for x in right
233
+ )
234
+
235
+
236
+ def _in_scalar_range(scalar, _range: tuple) -> bool:
237
+ """
238
+ Check if a scalar is within a range.
239
+
240
+ Args:
241
+ scalar: The scalar value to check.
242
+ _range: A tuple representing the range (start, end).
243
+
244
+ Returns:
245
+ True if the scalar is within the range, otherwise False.
246
+ """
247
+ start, end = _range
248
+ start, end = min(start, end), max(start, end)
249
+ return binary_operation(">=", scalar, start) and binary_operation("<=", scalar, end)
250
+
251
+
252
+ def _in_list_tuple(t: MutableSequence, _range: tuple) -> bool:
253
+ """
254
+ Check if any element from the list is within a range.
255
+
256
+ Args:
257
+ t: The list of values to check.
258
+ _range: A tuple representing the range (start, end).
259
+
260
+ Returns:
261
+ True if any element of the list is within the range, otherwise False.
262
+ """
263
+ return any(_in_scalar_range(elem, _range) for elem in t)
264
+
265
+
266
+ def _in_list_list(left: MutableSequence, right: MutableSequence) -> bool:
267
+ """
268
+ Check if any element of one list is in another list.
269
+
270
+ Args:
271
+ left: The first list of elements.
272
+ right: The second list to search in.
273
+
274
+ Returns:
275
+ True if any element of `left` is in `right`, otherwise False.
276
+ """
277
+ return any(_in_scalar_list(elem, right) for elem in left)
278
+
279
+
280
+ def _set_zero_timezone_if_none(
281
+ left: datetime, right: datetime
282
+ ) -> tuple[datetime, datetime]:
283
+ """
284
+ Set UTC timezone to datetime objects if they are offset-naive.
285
+
286
+ Args:
287
+ left: The first datetime object.
288
+ right: The second datetime object.
289
+
290
+ Returns:
291
+ A tuple of datetime objects with UTC timezone set if they were naive.
292
+ """
293
+ if left.tzinfo is None:
294
+ left = left.replace(tzinfo=timezone.utc)
295
+ if right.tzinfo is None:
296
+ right = right.replace(tzinfo=timezone.utc)
297
+ return left, right
298
+
299
+
300
+ def _comp_datetime_datetime(comp: Callable, left: datetime, right: datetime) -> bool:
301
+ """
302
+ Compare two datetime objects with a given comparison function,
303
+ ensuring both are timezone-aware.
304
+
305
+ Args:
306
+ comp: A callable comparison function (e.g., operator.lt).
307
+ left: The first datetime object.
308
+ right: The second datetime object.
309
+
310
+ Returns:
311
+ The result of the comparison.
312
+ """
313
+ left, right = _set_zero_timezone_if_none(left, right)
314
+ return comp(left, right)
315
+
316
+
317
+ def _sub_datetime_datetime(left: datetime, right: datetime) -> timedelta:
318
+ """
319
+ Subtract two datetime objects, ensuring both are timezone-aware.
320
+
321
+ Args:
322
+ left: The first datetime object.
323
+ right: The second datetime object.
324
+
325
+ Returns:
326
+ The time difference as a timedelta object.
327
+ """
328
+ left, right = _set_zero_timezone_if_none(left, right)
329
+ return left - right
330
+
331
+
332
+ def _get_comp_dict(op: str, comp: Callable) -> dict[tuple[Operand, Operand], Callable]:
333
+ """
334
+ Generate a comparison dictionary for the given operator.
335
+
336
+ Args:
337
+ op: The comparison operator.
338
+ comp: The comparison function.
339
+
340
+ Returns:
341
+ A dictionary mapping operand type pairs to comparison functions.
342
+ """
343
+ return {
344
+ (Number, Number): comp,
345
+ (datetime, datetime): partial(_comp_datetime_datetime, comp),
346
+ (timedelta, timedelta): comp,
347
+ ("ip", "ip"): partial(_comp_ip_ip, op),
348
+ (Number, tuple): partial(_comp_scalar_range, op),
349
+ (datetime, tuple): partial(_comp_scalar_range, op),
350
+ (tuple, Number): partial(_comp_range_scalar, op),
351
+ (tuple, datetime): partial(_comp_range_scalar, op),
352
+ ("ip", MutableSequence): partial(_comp_scalar_list, op),
353
+ (Number, MutableSequence): partial(_comp_scalar_list, op),
354
+ (datetime, MutableSequence): partial(_comp_scalar_list, op),
355
+ (timedelta, MutableSequence): partial(_comp_scalar_list, op),
356
+ (MutableSequence, "ip"): partial(_comp_list_scalar, op),
357
+ (MutableSequence, Number): partial(_comp_list_scalar, op),
358
+ (MutableSequence, datetime): partial(_comp_list_scalar, op),
359
+ (MutableSequence, timedelta): partial(_comp_list_scalar, op),
360
+ (MutableSequence, MutableSequence): partial(_comp_list_list, op),
361
+ }
362
+
363
+
364
+ # Define the operator map
365
+ _operator_map: dict[str, dict[tuple[Operand, Operand], Callable]] = {
366
+ "+": {
367
+ (Number, Number): add,
368
+ (datetime, timedelta): add,
369
+ (timedelta, timedelta): add,
370
+ (Number, tuple): partial(_op_scalar_range, "+"),
371
+ (timedelta, tuple): partial(_op_scalar_range, "+"),
372
+ (Number, MutableSequence): partial(_op_scalar_list, "+"),
373
+ (datetime, MutableSequence): partial(_op_scalar_list, "+"),
374
+ (timedelta, MutableSequence): partial(_op_scalar_list, "+"),
375
+ },
376
+ "-": {
377
+ (Number, Number): sub,
378
+ (datetime, timedelta): sub,
379
+ (timedelta, timedelta): sub,
380
+ (datetime, datetime): _sub_datetime_datetime,
381
+ # x - tuple[x]
382
+ (Number, tuple): partial(_op_scalar_range, "-"),
383
+ (timedelta, tuple): partial(_op_scalar_range, "-"),
384
+ (datetime, tuple): partial(_op_scalar_range, "-"),
385
+ # tuple[x] - x
386
+ (tuple, Number): partial(_op_range_scalar, "-"),
387
+ (tuple, timedelta): partial(_op_range_scalar, "-"),
388
+ (tuple, datetime): partial(_op_range_scalar, "-"),
389
+ # x - list[x]
390
+ (Number, MutableSequence): partial(_op_scalar_list, "-"),
391
+ (datetime, MutableSequence): partial(_op_scalar_list, "-"),
392
+ (timedelta, MutableSequence): partial(_op_scalar_list, "-"),
393
+ # list[x] - x
394
+ (MutableSequence, Number): partial(_op_list_scalar, "-"),
395
+ (MutableSequence, datetime): partial(_op_list_scalar, "-"),
396
+ (MutableSequence, timedelta): partial(_op_list_scalar, "-"),
397
+ },
398
+ "*": {
399
+ (timedelta, Number): mul,
400
+ (Number, Number): mul,
401
+ (timedelta, MutableSequence): partial(_op_scalar_list, "*"),
402
+ (Number, MutableSequence): partial(_op_scalar_list, "*"),
403
+ },
404
+ "/": {
405
+ (timedelta, timedelta): truediv,
406
+ (Number, Number): truediv,
407
+ (timedelta, MutableSequence): partial(_op_scalar_list, "/"),
408
+ (Number, MutableSequence): partial(_op_scalar_list, "/"),
409
+ (MutableSequence, timedelta): partial(_op_list_scalar, "/"),
410
+ (MutableSequence, Number): partial(_op_list_scalar, "/"),
411
+ },
412
+ "%": {
413
+ (timedelta, timedelta): mod,
414
+ (Number, Number): mod,
415
+ (timedelta, MutableSequence): partial(_op_scalar_list, "%"),
416
+ (Number, MutableSequence): partial(_op_scalar_list, "%"),
417
+ (MutableSequence, timedelta): partial(_op_list_scalar, "%"),
418
+ (MutableSequence, Number): partial(_op_list_scalar, "%"),
419
+ },
420
+ ">": _get_comp_dict(">", gt),
421
+ ">=": _get_comp_dict(">=", ge),
422
+ "<=": _get_comp_dict("<=", le),
423
+ "<": _get_comp_dict("<", lt),
424
+ "=": _get_comp_dict("=", eq),
425
+ ".": {
426
+ (str, str): _concat,
427
+ (MutableSequence, MutableSequence): _concat,
428
+ },
429
+ "contains": {
430
+ (str, str): lambda value, pattern: pattern in value,
431
+ (MutableSequence, str): lambda t, x: any(x in elem for elem in t),
432
+ },
433
+ "like": {(str, str): lambda data, pattern: re.match(pattern, data) is not None},
434
+ "in": {
435
+ ("ip", "ip"): lambda left, right: left in right,
436
+ (str, MutableSequence): _in_scalar_list,
437
+ (Number, MutableSequence): _in_scalar_list,
438
+ (timedelta, MutableSequence): _in_scalar_list,
439
+ (datetime, MutableSequence): _in_scalar_list,
440
+ ("ip", MutableSequence): _in_scalar_list,
441
+ ("ip", tuple): _in_scalar_range,
442
+ (Number, tuple): _in_scalar_range,
443
+ (datetime, tuple): _in_scalar_range,
444
+ (MutableSequence, tuple): _in_list_tuple,
445
+ (MutableSequence, MutableSequence): _in_list_list,
446
+ (MutableSequence, "ip"): lambda left, right: any(
447
+ binary_operation("in", x, right) for x in left
448
+ ),
449
+ },
450
+ }
451
+
452
+
453
+ def _resolve_type(value: Any) -> type | str:
454
+ """
455
+ Resolve the type of a value for use in operator mapping.
456
+
457
+ Args:
458
+ value: The value to resolve.
459
+
460
+ Returns:
461
+ The resolved type or a custom type string.
462
+ """
463
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
464
+ return Number
465
+ if isinstance(value, (IP4, IP4Range, IP4Net, IP6, IP6Net, IP6Range)):
466
+ return "ip"
467
+ if isinstance(value, MutableSequence):
468
+ return MutableSequence
469
+ return type(value)
470
+
471
+
472
+ def binary_operation(operator: str, left: Any, right: Any) -> Any:
473
+ """
474
+ Perform a binary operation using the specified operator and operands.
475
+
476
+ Args:
477
+ operator: The operator to apply.
478
+ left: The left operand.
479
+ right: The right operand.
480
+
481
+ Returns:
482
+ The result of the operation.
483
+
484
+ Raises:
485
+ OperatorNotFoundError: If the operator is not found for the operand types.
486
+ """
487
+ t1 = _resolve_type(left)
488
+ t2 = _resolve_type(right)
489
+ try:
490
+ # Try to find and apply the operator for the (t1, t2) combination
491
+ return _operator_map[operator][(t1, t2)](left, right)
492
+ except KeyError:
493
+ # If not found, check if the operator is commutative and try the reverse order
494
+ if operator in commutative_operators:
495
+ try:
496
+ return _operator_map[operator][(t2, t1)](right, left)
497
+ except KeyError:
498
+ pass
499
+ # Raise an error if the operator is not found in either order
500
+ raise OperatorNotFoundError(operator, (t1, t2), (left, right)) from None