exdrf 0.0.1.dev0__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.
- exdrf/__init__.py +0 -0
- exdrf/__version__.py +24 -0
- exdrf/api.py +51 -0
- exdrf/constants.py +30 -0
- exdrf/dataset.py +197 -0
- exdrf/field.py +554 -0
- exdrf/field_types/__init__.py +0 -0
- exdrf/field_types/api.py +78 -0
- exdrf/field_types/blob_field.py +44 -0
- exdrf/field_types/bool_field.py +47 -0
- exdrf/field_types/date_field.py +49 -0
- exdrf/field_types/date_time.py +52 -0
- exdrf/field_types/dur_field.py +44 -0
- exdrf/field_types/enum_field.py +41 -0
- exdrf/field_types/filter_field.py +11 -0
- exdrf/field_types/float_field.py +85 -0
- exdrf/field_types/float_list.py +18 -0
- exdrf/field_types/formatted.py +39 -0
- exdrf/field_types/int_field.py +70 -0
- exdrf/field_types/int_list.py +18 -0
- exdrf/field_types/ref_base.py +105 -0
- exdrf/field_types/ref_m2m.py +39 -0
- exdrf/field_types/ref_m2o.py +23 -0
- exdrf/field_types/ref_o2m.py +36 -0
- exdrf/field_types/ref_o2o.py +32 -0
- exdrf/field_types/sort_field.py +18 -0
- exdrf/field_types/str_field.py +77 -0
- exdrf/field_types/str_list.py +18 -0
- exdrf/field_types/time_field.py +49 -0
- exdrf/filter.py +653 -0
- exdrf/filter_dsl.py +950 -0
- exdrf/filter_op_catalog.py +222 -0
- exdrf/label_dsl.py +691 -0
- exdrf/moment.py +496 -0
- exdrf/py.typed +0 -0
- exdrf/py_support.py +21 -0
- exdrf/resource.py +901 -0
- exdrf/sa_fi_item.py +69 -0
- exdrf/sa_filter_op.py +324 -0
- exdrf/utils.py +17 -0
- exdrf/validator.py +45 -0
- exdrf/var_bag.py +328 -0
- exdrf/visitor.py +58 -0
- exdrf-0.0.1.dev0.dist-info/METADATA +42 -0
- exdrf-0.0.1.dev0.dist-info/RECORD +57 -0
- exdrf-0.0.1.dev0.dist-info/WHEEL +5 -0
- exdrf-0.0.1.dev0.dist-info/top_level.txt +3 -0
- exdrf_tests/__init__.py +0 -0
- exdrf_tests/test_dataset.py +422 -0
- exdrf_tests/test_field.py +109 -0
- exdrf_tests/test_filter.py +425 -0
- exdrf_tests/test_filter_dsl.py +556 -0
- exdrf_tests/test_label_dsl.py +234 -0
- exdrf_tests/test_resource.py +107 -0
- exdrf_tests/test_utils.py +43 -0
- exdrf_tests/test_visitor.py +31 -0
- exdrf_tests/var_bag_test.py +502 -0
exdrf/label_dsl.py
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""Domain-specific language for parsing label expressions.
|
|
2
|
+
|
|
3
|
+
The expression always starts with an open parenthesis and ends with a close
|
|
4
|
+
parenthesis. Nested statements are also surrounded by parentheses. The first
|
|
5
|
+
element in the expression is the operator, and the rest are its arguments.
|
|
6
|
+
Strings are surrounded by double quotes. Other identifiers are treated as
|
|
7
|
+
properties of the SQLAlchemy model instance.
|
|
8
|
+
|
|
9
|
+
You can use this DSL to associate labels with Resources.
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
(concat first_name " " last_name)
|
|
15
|
+
(if (upper name) "Yes" "No")
|
|
16
|
+
(concat (upper first_name) (lower last_name))
|
|
17
|
+
(concat (upper name.first) (lower name.last))
|
|
18
|
+
(is_none attrib "Is none" "Is not none")
|
|
19
|
+
```
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import Any, Dict, List, Literal, Union, cast
|
|
24
|
+
|
|
25
|
+
from attrs import define, field
|
|
26
|
+
|
|
27
|
+
ASTNode = Union["ParsedOp", "ParsedLiteral", "ParsedIdentifier", List["ASTNode"]]
|
|
28
|
+
|
|
29
|
+
# \s*: This part matches zero or more whitespace characters.
|
|
30
|
+
# (\(|\)|\"[^\"]*\"|[^\s()]+): This is the main capturing group, enclosed
|
|
31
|
+
# in parentheses (). It uses the | operator (logical OR) to match one of
|
|
32
|
+
# four possible patterns:
|
|
33
|
+
# \(: Matches an opening parenthesis (.
|
|
34
|
+
# \): Matches a closing parenthesis ).
|
|
35
|
+
# \"[^\"]*\": Matches a double-quoted string.
|
|
36
|
+
# [^\s()]+: Matches one or more characters that are not whitespace (\s),
|
|
37
|
+
# an opening parenthesis (, or a closing parenthesis ).
|
|
38
|
+
# The + quantifier ensures that at least one such character is matched.
|
|
39
|
+
# This is useful for capturing standalone tokens or words.
|
|
40
|
+
token_pattern = re.compile(r"\s*(" r"\(|\)|\"[^\"]*\"|" r"[^\s()]+" ")")
|
|
41
|
+
|
|
42
|
+
# Simple regex patterns to match integers.
|
|
43
|
+
int_pattern = re.compile(r"^\d+$")
|
|
44
|
+
|
|
45
|
+
# Simple regex patterns to match floats.
|
|
46
|
+
float_pattern = re.compile(r"^\d+\.\d+$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Null:
|
|
50
|
+
"""A class to represent a null value."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@define(eq=False)
|
|
54
|
+
class Parsed:
|
|
55
|
+
"""Base class for parsed elements in the AST.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
value: The string value of the parsed element.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
value: str
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str:
|
|
64
|
+
return self.value
|
|
65
|
+
|
|
66
|
+
def __eq__(self, other: object) -> bool:
|
|
67
|
+
if isinstance(other, Parsed):
|
|
68
|
+
return self.value == other.value
|
|
69
|
+
elif isinstance(other, str):
|
|
70
|
+
return self.value == other
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@define(eq=False)
|
|
75
|
+
class ParsedOp(Parsed):
|
|
76
|
+
"""Parsed operator in the AST.
|
|
77
|
+
|
|
78
|
+
This is the first element of an expression and indicates the operation to
|
|
79
|
+
be performed on the subsequent elements.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@define(eq=False)
|
|
84
|
+
class ParsedLiteral(Parsed):
|
|
85
|
+
"""Parsed literal in the AST.
|
|
86
|
+
|
|
87
|
+
Arguments can be either literals (this class) or identifiers. The literals
|
|
88
|
+
can be strings (denoted by double quotes), integers, or floats.
|
|
89
|
+
|
|
90
|
+
Attributes:
|
|
91
|
+
type: The type of the literal (string, int, float).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
type: Literal["string", "int", "float"] = field(default="string")
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def as_string(self) -> str:
|
|
98
|
+
"""The value ass it can be used in a string representation.
|
|
99
|
+
|
|
100
|
+
Strings are double-quoted, numbers are inserted as they are.
|
|
101
|
+
"""
|
|
102
|
+
if self.type == "string":
|
|
103
|
+
return f'"{self.value}"'
|
|
104
|
+
else:
|
|
105
|
+
return self.value
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def raw_value(self) -> Any:
|
|
109
|
+
"""Python value representation.
|
|
110
|
+
|
|
111
|
+
Strings are returned as they are (we store strings), numbers
|
|
112
|
+
are converted to integers or reals.
|
|
113
|
+
"""
|
|
114
|
+
if self.type == "string":
|
|
115
|
+
return self.value
|
|
116
|
+
elif self.type == "int":
|
|
117
|
+
return int(self.value)
|
|
118
|
+
elif self.type == "float":
|
|
119
|
+
return float(self.value)
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError(f"Unknown type: {self.type}")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def ensure_str(self) -> Any:
|
|
125
|
+
"""Python value representation."""
|
|
126
|
+
if self.type == "string":
|
|
127
|
+
return self.value
|
|
128
|
+
elif self.type == "int":
|
|
129
|
+
return int(self.value)
|
|
130
|
+
elif self.type == "float":
|
|
131
|
+
return float(self.value)
|
|
132
|
+
else:
|
|
133
|
+
raise ValueError(f"Unknown type: {self.type}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@define(eq=False)
|
|
137
|
+
class ParsedIdentifier(Parsed):
|
|
138
|
+
"""Parsed identifier in the AST.
|
|
139
|
+
|
|
140
|
+
The identifier is a type of argument that can retrieve the actual value
|
|
141
|
+
from the resource.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def retrieve(self, context: Any) -> Any:
|
|
145
|
+
"""Retrieve the value of the identifier from the context.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
context: The context in which to evaluate the expression.
|
|
149
|
+
"""
|
|
150
|
+
parts = self.value.split(".")
|
|
151
|
+
attr = context
|
|
152
|
+
|
|
153
|
+
if isinstance(attr, dict):
|
|
154
|
+
for part in parts:
|
|
155
|
+
attr = attr.get(part, Null)
|
|
156
|
+
if attr is Null:
|
|
157
|
+
raise AttributeError(f"Value `{part}` not found in {attr}")
|
|
158
|
+
else:
|
|
159
|
+
for part in parts:
|
|
160
|
+
attr = getattr(attr, part, Null)
|
|
161
|
+
if attr is Null:
|
|
162
|
+
raise AttributeError(f"Attribute `{part}` not found in {attr}")
|
|
163
|
+
return attr
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@define
|
|
167
|
+
class Operation:
|
|
168
|
+
"""Base class for operations in the DSL.
|
|
169
|
+
|
|
170
|
+
The ParsedOp will end up an instance of this class, which will be used to
|
|
171
|
+
evaluate the expression.
|
|
172
|
+
|
|
173
|
+
Attributes:
|
|
174
|
+
key: The key that identifies the operation.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
key: str
|
|
178
|
+
|
|
179
|
+
def evaluate(self, *args) -> str:
|
|
180
|
+
"""Evaluate the operation online with the given arguments."""
|
|
181
|
+
raise NotImplementedError("Subclasses should implement this!")
|
|
182
|
+
|
|
183
|
+
def to_python(self, *args) -> str:
|
|
184
|
+
"""Generate Python code for the operation."""
|
|
185
|
+
raise NotImplementedError("Subclasses should implement this!")
|
|
186
|
+
|
|
187
|
+
def to_typescript(self, *args) -> str:
|
|
188
|
+
"""Generate TypeScript code for the operation."""
|
|
189
|
+
raise NotImplementedError("Subclasses should implement this!")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@define
|
|
193
|
+
class Concat(Operation):
|
|
194
|
+
"""Concatenate strings together.
|
|
195
|
+
|
|
196
|
+
Example:
|
|
197
|
+
(concat first_name " " last_name) -> "John Doe"
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
key: str = field(default="concat", init=False)
|
|
201
|
+
|
|
202
|
+
def evaluate(self, *args) -> Any:
|
|
203
|
+
return "".join(map(str, args))
|
|
204
|
+
|
|
205
|
+
def to_python(self, *args) -> str:
|
|
206
|
+
return "(" + " + ".join([f"str({a})" for a in args]) + ")"
|
|
207
|
+
|
|
208
|
+
def to_typescript(self, *args) -> str:
|
|
209
|
+
return "(" + " + ".join(args) + ")"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@define
|
|
213
|
+
class If(Operation):
|
|
214
|
+
"""If statement.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
(if (upper name) "Yes" "No") -> "Yes" if name is upper, else "No"
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
key: str = field(default="if", init=False)
|
|
221
|
+
|
|
222
|
+
def evaluate(self, *args) -> Any:
|
|
223
|
+
assert len(args) == 3, (
|
|
224
|
+
f"If operation takes three arguments, got {len(args)}: "
|
|
225
|
+
f"{'\n'.join(map(str, args))}"
|
|
226
|
+
)
|
|
227
|
+
cond, a, b = args
|
|
228
|
+
return a if cond else b
|
|
229
|
+
|
|
230
|
+
def to_python(self, *args) -> str:
|
|
231
|
+
assert len(args) == 3, (
|
|
232
|
+
f"If operation takes three arguments, got {len(args)}: "
|
|
233
|
+
f"{'\n'.join(map(str, args))}"
|
|
234
|
+
)
|
|
235
|
+
cond, a, b = args
|
|
236
|
+
return f"({a} if {cond} else {b})"
|
|
237
|
+
|
|
238
|
+
def to_typescript(self, *args) -> str:
|
|
239
|
+
assert len(args) == 3, (
|
|
240
|
+
f"If operation takes three arguments, got {len(args)}: "
|
|
241
|
+
f"{'\n'.join(map(str, args))}"
|
|
242
|
+
)
|
|
243
|
+
cond, a, b = args
|
|
244
|
+
return f"({cond} ? {a} : {b})"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@define
|
|
248
|
+
class Upper(Operation):
|
|
249
|
+
"""Convert string to upper case.
|
|
250
|
+
|
|
251
|
+
Example:
|
|
252
|
+
(upper name) -> "JOHN DOE"
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
key: str = field(default="upper", init=False)
|
|
256
|
+
|
|
257
|
+
def evaluate(self, *args) -> Any:
|
|
258
|
+
assert len(args) == 1, (
|
|
259
|
+
f"Upper operation takes one argument, got {len(args)}: "
|
|
260
|
+
f"{'\n'.join(map(str, args))}"
|
|
261
|
+
)
|
|
262
|
+
(s,) = args
|
|
263
|
+
return str(s).upper()
|
|
264
|
+
|
|
265
|
+
def to_python(self, *args) -> str:
|
|
266
|
+
assert len(args) == 1, (
|
|
267
|
+
f"Upper operation takes one argument, got {len(args)}: "
|
|
268
|
+
f"{'\n'.join(map(str, args))}"
|
|
269
|
+
)
|
|
270
|
+
(s,) = args
|
|
271
|
+
return f"str({s}).upper()"
|
|
272
|
+
|
|
273
|
+
def to_typescript(self, *args) -> str:
|
|
274
|
+
assert len(args) == 1, (
|
|
275
|
+
f"Upper operation takes one argument, got {len(args)}: "
|
|
276
|
+
f"{'\n'.join(map(str, args))}"
|
|
277
|
+
)
|
|
278
|
+
(s,) = args
|
|
279
|
+
return f"String({s}).toUpperCase()"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@define
|
|
283
|
+
class Lower(Operation):
|
|
284
|
+
"""Convert string to lower case.
|
|
285
|
+
|
|
286
|
+
Example:
|
|
287
|
+
(lower name) -> "john doe"
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
key: str = field(default="lower", init=False)
|
|
291
|
+
|
|
292
|
+
def evaluate(self, *args) -> Any:
|
|
293
|
+
assert len(args) == 1, (
|
|
294
|
+
f"Lower operation takes one argument, got {len(args)}: "
|
|
295
|
+
f"{'\n'.join(map(str, args))}"
|
|
296
|
+
)
|
|
297
|
+
(s,) = args
|
|
298
|
+
return str(s).lower()
|
|
299
|
+
|
|
300
|
+
def to_python(self, *args) -> str:
|
|
301
|
+
assert len(args) == 1, (
|
|
302
|
+
f"Lower operation takes one argument, got {len(args)}: "
|
|
303
|
+
f"{'\n'.join(map(str, args))}"
|
|
304
|
+
)
|
|
305
|
+
(s,) = args
|
|
306
|
+
return f"str({s}).lower()"
|
|
307
|
+
|
|
308
|
+
def to_typescript(self, *args) -> str:
|
|
309
|
+
assert len(args) == 1, (
|
|
310
|
+
f"Lower operation takes one argument, got {len(args)}: "
|
|
311
|
+
f"{'\n'.join(map(str, args))}"
|
|
312
|
+
)
|
|
313
|
+
(s,) = args
|
|
314
|
+
return f"String({s}).toLowerCase()"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@define
|
|
318
|
+
class IsNone(Operation):
|
|
319
|
+
"""Check if the first argument is None.
|
|
320
|
+
|
|
321
|
+
Example:
|
|
322
|
+
(is_none attrib "Is none" "Is not none") -> "Is none"
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
key: str = field(default="is_none", init=False)
|
|
326
|
+
|
|
327
|
+
def evaluate(self, *args) -> Any:
|
|
328
|
+
assert len(args) == 3, (
|
|
329
|
+
f"IsNone operation takes three arguments, got {len(args)}: "
|
|
330
|
+
f"{'\n'.join(map(str, args))}"
|
|
331
|
+
)
|
|
332
|
+
cond, a, b = args
|
|
333
|
+
return a if cond is None else b
|
|
334
|
+
|
|
335
|
+
def to_python(self, *args) -> str:
|
|
336
|
+
assert len(args) == 3, (
|
|
337
|
+
f"IsNone operation takes three arguments, got {len(args)}: "
|
|
338
|
+
f"{'\n'.join(map(str, args))}"
|
|
339
|
+
)
|
|
340
|
+
cond, a, b = args
|
|
341
|
+
return f"({a} if {cond} is None else {b})"
|
|
342
|
+
|
|
343
|
+
def to_typescript(self, *args) -> str:
|
|
344
|
+
assert len(args) == 3, (
|
|
345
|
+
f"IsNone operation takes three arguments, got {len(args)}: "
|
|
346
|
+
f"{'\n'.join(map(str, args))}"
|
|
347
|
+
)
|
|
348
|
+
cond, a, b = args
|
|
349
|
+
return f"(({cond} == null || {cond} == undefined) ? {a} : {b})"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@define
|
|
353
|
+
class Equals(Operation):
|
|
354
|
+
"""Check if the first argument is equal to the second.
|
|
355
|
+
|
|
356
|
+
Example:
|
|
357
|
+
(= name "John Doe" "Yes" "No") -> "Yes"
|
|
358
|
+
"""
|
|
359
|
+
|
|
360
|
+
key: str = field(default="=", init=False)
|
|
361
|
+
|
|
362
|
+
def evaluate(self, *args) -> Any:
|
|
363
|
+
assert len(args) == 4, (
|
|
364
|
+
f"Equals operation takes four arguments, got {len(args)}: "
|
|
365
|
+
f"{'\n'.join(map(str, args))}"
|
|
366
|
+
)
|
|
367
|
+
cond1, cond2, a, b = args
|
|
368
|
+
return a if cond1 == cond2 else b
|
|
369
|
+
|
|
370
|
+
def to_python(self, *args) -> str:
|
|
371
|
+
assert len(args) == 4, (
|
|
372
|
+
f"Equals operation takes four arguments, got {len(args)}: "
|
|
373
|
+
f"{'\n'.join(map(str, args))}"
|
|
374
|
+
)
|
|
375
|
+
cond1, cond2, a, b = args
|
|
376
|
+
return f"({a} if {cond1} == {cond2} else {b})"
|
|
377
|
+
|
|
378
|
+
def to_typescript(self, *args) -> str:
|
|
379
|
+
assert len(args) == 4, (
|
|
380
|
+
f"Equals operation takes four arguments, got {len(args)}: "
|
|
381
|
+
f"{'\n'.join(map(str, args))}"
|
|
382
|
+
)
|
|
383
|
+
cond1, cond2, a, b = args
|
|
384
|
+
return f"(({cond1} == {cond2}) ? {a} : {b})"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@define
|
|
388
|
+
class DateStr(Operation):
|
|
389
|
+
"""Convert date to string using strftime.
|
|
390
|
+
|
|
391
|
+
Note that this relies on the existence of a strftime method
|
|
392
|
+
on the date class in javascript, which is not standard.
|
|
393
|
+
|
|
394
|
+
Example:
|
|
395
|
+
(date_str date "%Y-%m-%d") -> "2023-10-01"
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
key: str = field(default="date_str", init=False)
|
|
399
|
+
|
|
400
|
+
def evaluate(self, *args) -> Any:
|
|
401
|
+
assert len(args) == 2, (
|
|
402
|
+
f"DateStr operation takes two arguments, got {len(args)}: "
|
|
403
|
+
f"{'\n'.join(map(str, args))}"
|
|
404
|
+
)
|
|
405
|
+
date, format = args
|
|
406
|
+
return date.strftime(format)
|
|
407
|
+
|
|
408
|
+
def to_python(self, *args) -> str:
|
|
409
|
+
assert len(args) == 2, (
|
|
410
|
+
f"DateStr operation takes two arguments, got {len(args)}: "
|
|
411
|
+
f"{'\n'.join(map(str, args))}"
|
|
412
|
+
)
|
|
413
|
+
date, format = args
|
|
414
|
+
return f"({date}.strftime({format}))"
|
|
415
|
+
|
|
416
|
+
def to_typescript(self, *args) -> str:
|
|
417
|
+
assert len(args) == 2, (
|
|
418
|
+
f"DateStr operation takes two arguments, got {len(args)}: "
|
|
419
|
+
f"{'\n'.join(map(str, args))}"
|
|
420
|
+
)
|
|
421
|
+
date, format = args
|
|
422
|
+
return f"({date}.strftime({format}))"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@define
|
|
426
|
+
class FloatStr(Operation):
|
|
427
|
+
"""Convert float to string with specified number of digits.
|
|
428
|
+
|
|
429
|
+
Example:
|
|
430
|
+
(float_str number digits) -> "123.45"
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
key: str = field(default="float_str", init=False)
|
|
434
|
+
|
|
435
|
+
def evaluate(self, *args) -> Any:
|
|
436
|
+
assert len(args) == 2, (
|
|
437
|
+
f"FloatStr operation takes two arguments, got {len(args)}: "
|
|
438
|
+
f"{'\n'.join(map(str, args))}"
|
|
439
|
+
)
|
|
440
|
+
number, digits = args
|
|
441
|
+
return ("{:." + str(digits) + "f}").format(number)
|
|
442
|
+
|
|
443
|
+
def to_python(self, *args) -> str:
|
|
444
|
+
assert len(args) == 2, (
|
|
445
|
+
f"FloatStr operation takes two arguments, got {len(args)}: "
|
|
446
|
+
f"{'\n'.join(map(str, args))}"
|
|
447
|
+
)
|
|
448
|
+
number, digits = args
|
|
449
|
+
return '("{:." + str(' + str(digits) + ') + "f}").format(' + str(number) + ")"
|
|
450
|
+
|
|
451
|
+
def to_typescript(self, *args) -> str:
|
|
452
|
+
assert len(args) == 2, (
|
|
453
|
+
f"FloatStr operation takes two arguments, got {len(args)}: "
|
|
454
|
+
f"{'\n'.join(map(str, args))}"
|
|
455
|
+
)
|
|
456
|
+
number, digits = args
|
|
457
|
+
return (
|
|
458
|
+
f"({number})."
|
|
459
|
+
"toLocaleString('en-US', { "
|
|
460
|
+
f"minimumFractionDigits: {digits}, "
|
|
461
|
+
f"maximumFractionDigits: {digits}"
|
|
462
|
+
"});"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@define
|
|
467
|
+
class IntStr(Operation):
|
|
468
|
+
"""Convert int to string with thousands separator.
|
|
469
|
+
|
|
470
|
+
Example:
|
|
471
|
+
(int_str number) -> "1,234,567"
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
key: str = field(default="int_str", init=False)
|
|
475
|
+
|
|
476
|
+
def evaluate(self, *args) -> Any:
|
|
477
|
+
assert len(args) == 1, (
|
|
478
|
+
f"IntStr operation takes one argument, got {len(args)}: "
|
|
479
|
+
f"{'\n'.join(map(str, args))}"
|
|
480
|
+
)
|
|
481
|
+
(number,) = args
|
|
482
|
+
return f"{number:,}"
|
|
483
|
+
|
|
484
|
+
def to_python(self, *args) -> str:
|
|
485
|
+
assert len(args) == 1, (
|
|
486
|
+
f"IntStr operation takes one argument, got {len(args)}: "
|
|
487
|
+
f"{'\n'.join(map(str, args))}"
|
|
488
|
+
)
|
|
489
|
+
(number,) = args
|
|
490
|
+
return 'f"{' + str(number) + ':,}"'
|
|
491
|
+
|
|
492
|
+
def to_typescript(self, *args) -> str:
|
|
493
|
+
assert len(args) == 1, (
|
|
494
|
+
f"IntStr operation takes one argument, got {len(args)}: "
|
|
495
|
+
f"{'\n'.join(map(str, args))}"
|
|
496
|
+
)
|
|
497
|
+
(number,) = args
|
|
498
|
+
return (
|
|
499
|
+
f"({number})."
|
|
500
|
+
"toLocaleString('en-US', { "
|
|
501
|
+
"minimumFractionDigits: 0, "
|
|
502
|
+
"maximumFractionDigits: 0"
|
|
503
|
+
"});"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
ops: Dict[str, Operation] = {
|
|
508
|
+
"concat": Concat(),
|
|
509
|
+
"if": If(),
|
|
510
|
+
"upper": Upper(),
|
|
511
|
+
"lower": Lower(),
|
|
512
|
+
"is_none": IsNone(),
|
|
513
|
+
"=": Equals(),
|
|
514
|
+
"date_str": DateStr(),
|
|
515
|
+
"float_str": FloatStr(),
|
|
516
|
+
"int_str": IntStr(),
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def parse_expr(expr: str) -> ASTNode:
|
|
521
|
+
"""Parses the label expression string into an AST.
|
|
522
|
+
|
|
523
|
+
Supports nested expressions like: (if (upper name) "Yes" "No")
|
|
524
|
+
"""
|
|
525
|
+
|
|
526
|
+
# Tokenize: parentheses, quoted strings, or other tokens
|
|
527
|
+
tokens = re.findall(token_pattern, expr)
|
|
528
|
+
|
|
529
|
+
def parse_tokens(tokens):
|
|
530
|
+
if not tokens:
|
|
531
|
+
raise SyntaxError("Unexpected EOF while reading")
|
|
532
|
+
|
|
533
|
+
token = tokens.pop(0)
|
|
534
|
+
|
|
535
|
+
if token == "(":
|
|
536
|
+
lst = []
|
|
537
|
+
try:
|
|
538
|
+
op_token = tokens.pop(0)
|
|
539
|
+
if not isinstance(op_token, str):
|
|
540
|
+
raise SyntaxError("Expected operator after `(`")
|
|
541
|
+
lst.append(ParsedOp(op_token))
|
|
542
|
+
|
|
543
|
+
while tokens[0] != ")":
|
|
544
|
+
lst.append(parse_tokens(tokens))
|
|
545
|
+
except IndexError:
|
|
546
|
+
raise SyntaxError("Unexpected EOF while expecting `)`")
|
|
547
|
+
tokens.pop(0) # Remove ')'
|
|
548
|
+
return lst
|
|
549
|
+
|
|
550
|
+
elif token == ")":
|
|
551
|
+
raise SyntaxError("Unexpected )")
|
|
552
|
+
|
|
553
|
+
elif token.startswith('"') and token.endswith('"'):
|
|
554
|
+
return ParsedLiteral(token[1:-1]) # Remove surrounding quotes
|
|
555
|
+
|
|
556
|
+
elif int_pattern.match(token):
|
|
557
|
+
return ParsedLiteral(token, type="int")
|
|
558
|
+
|
|
559
|
+
elif float_pattern.match(token):
|
|
560
|
+
return ParsedLiteral(token, type="float")
|
|
561
|
+
|
|
562
|
+
else:
|
|
563
|
+
return ParsedIdentifier(token)
|
|
564
|
+
|
|
565
|
+
ast = parse_tokens(tokens)
|
|
566
|
+
if tokens:
|
|
567
|
+
raise SyntaxError("Unexpected tokens after parsing")
|
|
568
|
+
return ast
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _eval_op(op: ParsedOp, args: List[Any]) -> Any:
|
|
572
|
+
"""Evaluate the operator with the given arguments.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
op: The operator to evaluate.
|
|
576
|
+
args: The arguments to the operator.
|
|
577
|
+
Returns:
|
|
578
|
+
The result of the evaluation.
|
|
579
|
+
"""
|
|
580
|
+
if op.value not in ops:
|
|
581
|
+
raise ValueError(f"Unknown operator: {op}")
|
|
582
|
+
return ops[op.value].evaluate(*args)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def evaluate(ast_node: ASTNode, context: Any) -> Any:
|
|
586
|
+
"""Evaluate the parsed AST in the context of a Resource instance.
|
|
587
|
+
|
|
588
|
+
The context is usually a SQLAlchemy model instance. The function will
|
|
589
|
+
replace identifiers with their values from the context and evaluate the
|
|
590
|
+
expression.
|
|
591
|
+
|
|
592
|
+
For example, if the context has a field `name`, and the AST is `["upper",
|
|
593
|
+
"name"]`, the function will return `context.name.upper()`.
|
|
594
|
+
|
|
595
|
+
Args:
|
|
596
|
+
ast_node: The parsed AST node.
|
|
597
|
+
context: The context in which to evaluate the expression.
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
The result of the evaluation.
|
|
601
|
+
"""
|
|
602
|
+
if isinstance(ast_node, ParsedIdentifier):
|
|
603
|
+
return ast_node.retrieve(context)
|
|
604
|
+
|
|
605
|
+
elif isinstance(ast_node, ParsedLiteral):
|
|
606
|
+
return ast_node.raw_value
|
|
607
|
+
|
|
608
|
+
elif isinstance(ast_node, list):
|
|
609
|
+
op = cast(ParsedOp, ast_node[0])
|
|
610
|
+
assert isinstance(op, ParsedOp), "First element must be an operator"
|
|
611
|
+
args = [evaluate(arg, context) for arg in ast_node[1:]]
|
|
612
|
+
return _eval_op(op, args)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def generate_python_code(ast_node: ASTNode) -> Any:
|
|
616
|
+
"""Generate Python code from the AST.
|
|
617
|
+
|
|
618
|
+
This function traverses the AST and generates Python code that can be
|
|
619
|
+
used to evaluate the expression.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
ast_node: The parsed AST node.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
The generated Python code as a string.
|
|
626
|
+
"""
|
|
627
|
+
if isinstance(ast_node, ParsedLiteral):
|
|
628
|
+
return ast_node.as_string
|
|
629
|
+
|
|
630
|
+
elif isinstance(ast_node, ParsedIdentifier):
|
|
631
|
+
return f"record.{ast_node}"
|
|
632
|
+
|
|
633
|
+
elif isinstance(ast_node, list):
|
|
634
|
+
op = cast(ParsedOp, ast_node[0])
|
|
635
|
+
args = [generate_python_code(arg) for arg in ast_node[1:]]
|
|
636
|
+
op_class = ops.get(op.value)
|
|
637
|
+
if op_class:
|
|
638
|
+
return op_class.to_python(*args)
|
|
639
|
+
else:
|
|
640
|
+
raise ValueError(f"Unsupported operator: {op}")
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def generate_typescript_code(ast_node: ASTNode) -> Any:
|
|
644
|
+
"""Generate TypeScript code from the AST.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
ast_node: The parsed AST node.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
The generated TypeScript code as a string.
|
|
651
|
+
"""
|
|
652
|
+
if isinstance(ast_node, ParsedIdentifier):
|
|
653
|
+
return f"record.{ast_node}"
|
|
654
|
+
|
|
655
|
+
elif isinstance(ast_node, ParsedLiteral):
|
|
656
|
+
return ast_node.as_string
|
|
657
|
+
|
|
658
|
+
elif isinstance(ast_node, list):
|
|
659
|
+
op = cast(ParsedOp, ast_node[0])
|
|
660
|
+
args = [generate_typescript_code(arg) for arg in ast_node[1:]]
|
|
661
|
+
op_class = ops.get(op.value)
|
|
662
|
+
if op_class:
|
|
663
|
+
return op_class.to_typescript(*args)
|
|
664
|
+
else:
|
|
665
|
+
raise ValueError(f"Unsupported operator: {op}")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def get_used_fields(ast: ASTNode) -> List[str]:
|
|
669
|
+
"""Get the list of fields used in the AST.
|
|
670
|
+
|
|
671
|
+
This function traverses the AST and collects all identifiers that are
|
|
672
|
+
valid Python identifiers. It returns a sorted list of these identifiers.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
ast: The parsed AST node.
|
|
676
|
+
|
|
677
|
+
Returns:
|
|
678
|
+
A sorted list of field names used in the AST.
|
|
679
|
+
"""
|
|
680
|
+
fields = set()
|
|
681
|
+
|
|
682
|
+
def walk(node):
|
|
683
|
+
if isinstance(node, ParsedIdentifier):
|
|
684
|
+
fields.add(node.value)
|
|
685
|
+
elif isinstance(node, list):
|
|
686
|
+
# skip operator
|
|
687
|
+
for sub in node[1:]:
|
|
688
|
+
walk(sub)
|
|
689
|
+
|
|
690
|
+
walk(ast)
|
|
691
|
+
return sorted(fields)
|