texer 0.5.12__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.
- texer/__init__.py +40 -0
- texer/eval.py +310 -0
- texer/pgfplots.py +1381 -0
- texer/specs.py +513 -0
- texer/tables.py +338 -0
- texer/utils.py +311 -0
- texer-0.5.12.dist-info/METADATA +263 -0
- texer-0.5.12.dist-info/RECORD +11 -0
- texer-0.5.12.dist-info/WHEEL +5 -0
- texer-0.5.12.dist-info/licenses/LICENSE +21 -0
- texer-0.5.12.dist-info/top_level.txt +1 -0
texer/specs.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""Core spec system for texer - glom-style specs with type checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Callable, Generic, TypeVar, Union
|
|
8
|
+
|
|
9
|
+
import glom # type: ignore[import-untyped]
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
U = TypeVar("U")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Spec(ABC):
|
|
16
|
+
"""Base class for all specs. Specs are lazy evaluation descriptors."""
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> Any:
|
|
20
|
+
"""Resolve this spec against the given data."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"{self.__class__.__name__}(...)"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Ref(Spec):
|
|
29
|
+
"""Reference to a data path using glom-style dot notation.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
`Ref("name")` -> `data["name"]`
|
|
33
|
+
`Ref("user.email")` -> `data["user"]["email"]`
|
|
34
|
+
`Ref("items.0.value")` -> `data["items"][0]["value"]`
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
path: str
|
|
38
|
+
default: Any = field(default=None)
|
|
39
|
+
|
|
40
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> Any:
|
|
41
|
+
"""Resolve the reference path against data."""
|
|
42
|
+
# Check scope first for locally bound variables
|
|
43
|
+
if scope and self.path in scope:
|
|
44
|
+
return scope[self.path]
|
|
45
|
+
|
|
46
|
+
# Use glom for path resolution
|
|
47
|
+
try:
|
|
48
|
+
return glom.glom(data, self.path)
|
|
49
|
+
except glom.PathAccessError:
|
|
50
|
+
if self.default is not None:
|
|
51
|
+
return self.default
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
if self.default is not None:
|
|
56
|
+
return f'Ref("{self.path}", default={self.default!r})'
|
|
57
|
+
return f'Ref("{self.path}")'
|
|
58
|
+
|
|
59
|
+
# Comparison operators for Cond
|
|
60
|
+
def __gt__(self, other: Any) -> Comparison:
|
|
61
|
+
return Comparison(self, ">", other)
|
|
62
|
+
|
|
63
|
+
def __lt__(self, other: Any) -> Comparison:
|
|
64
|
+
return Comparison(self, "<", other)
|
|
65
|
+
|
|
66
|
+
def __ge__(self, other: Any) -> Comparison:
|
|
67
|
+
return Comparison(self, ">=", other)
|
|
68
|
+
|
|
69
|
+
def __le__(self, other: Any) -> Comparison:
|
|
70
|
+
return Comparison(self, "<=", other)
|
|
71
|
+
|
|
72
|
+
def __eq__(self, other: Any) -> Comparison: # type: ignore[override]
|
|
73
|
+
return Comparison(self, "==", other)
|
|
74
|
+
|
|
75
|
+
def __ne__(self, other: Any) -> Comparison: # type: ignore[override]
|
|
76
|
+
return Comparison(self, "!=", other)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Comparison(Spec):
|
|
81
|
+
"""A comparison expression for use in Cond."""
|
|
82
|
+
|
|
83
|
+
left: Spec
|
|
84
|
+
op: str
|
|
85
|
+
right: Any
|
|
86
|
+
|
|
87
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> bool:
|
|
88
|
+
"""Evaluate the comparison."""
|
|
89
|
+
left_val = resolve_value(self.left, data, scope)
|
|
90
|
+
right_val = resolve_value(self.right, data, scope)
|
|
91
|
+
|
|
92
|
+
ops: dict[str, Any] = {
|
|
93
|
+
">": lambda a, b: a > b,
|
|
94
|
+
"<": lambda a, b: a < b,
|
|
95
|
+
">=": lambda a, b: a >= b,
|
|
96
|
+
"<=": lambda a, b: a <= b,
|
|
97
|
+
"==": lambda a, b: a == b,
|
|
98
|
+
"!=": lambda a, b: a != b,
|
|
99
|
+
}
|
|
100
|
+
return bool(ops[self.op](left_val, right_val))
|
|
101
|
+
|
|
102
|
+
def __repr__(self) -> str:
|
|
103
|
+
return f"({self.left!r} {self.op} {self.right!r})"
|
|
104
|
+
|
|
105
|
+
def __and__(self, other: Comparison) -> And:
|
|
106
|
+
return And(self, other)
|
|
107
|
+
|
|
108
|
+
def __or__(self, other: Comparison) -> Or:
|
|
109
|
+
return Or(self, other)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class And(Spec):
|
|
114
|
+
"""Logical AND of two conditions."""
|
|
115
|
+
|
|
116
|
+
left: Spec
|
|
117
|
+
right: Spec
|
|
118
|
+
|
|
119
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> bool:
|
|
120
|
+
return bool(resolve_value(self.left, data, scope)) and bool(
|
|
121
|
+
resolve_value(self.right, data, scope)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class Or(Spec):
|
|
127
|
+
"""Logical OR of two conditions."""
|
|
128
|
+
|
|
129
|
+
left: Spec
|
|
130
|
+
right: Spec
|
|
131
|
+
|
|
132
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> bool:
|
|
133
|
+
return bool(resolve_value(self.left, data, scope)) or bool(
|
|
134
|
+
resolve_value(self.right, data, scope)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class Iter(Spec):
|
|
140
|
+
"""Iterate over a collection, applying a template to each item.
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
Iter(Ref("items"), template=Row(Ref("name"), Ref("value")))
|
|
144
|
+
Iter(Ref("points"), x=Ref("x"), y=Ref("y")) # For coordinates
|
|
145
|
+
Iter(Ref("points"), x=Ref("x"), y=Ref("y"), marker_size=Ref("size")) # With marker sizes
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
source: Spec | str
|
|
149
|
+
template: Any = None
|
|
150
|
+
separator: str = "\n"
|
|
151
|
+
# For coordinate-style iteration
|
|
152
|
+
x: Spec | None = None
|
|
153
|
+
y: Spec | None = None
|
|
154
|
+
z: Spec | None = None
|
|
155
|
+
marker_size: Spec | None = None # For data-driven marker sizes
|
|
156
|
+
|
|
157
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> Any:
|
|
158
|
+
"""Resolve by iterating over source and applying template."""
|
|
159
|
+
# Resolve the source collection
|
|
160
|
+
if isinstance(self.source, str):
|
|
161
|
+
try:
|
|
162
|
+
items = glom.glom(data, self.source)
|
|
163
|
+
except glom.PathAccessError as e:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"Iter source path '{self.source}' not found in data. "
|
|
166
|
+
f"Available keys: {list(data.keys()) if isinstance(data, dict) else 'N/A'}. "
|
|
167
|
+
f"Original error: {e}"
|
|
168
|
+
) from e
|
|
169
|
+
else:
|
|
170
|
+
items = self.source.resolve(data, scope)
|
|
171
|
+
|
|
172
|
+
if items is None:
|
|
173
|
+
raise TypeError(
|
|
174
|
+
f"Iter source resolved to None. "
|
|
175
|
+
f"Source: {self.source!r}. "
|
|
176
|
+
f"Ensure the data path exists and contains a valid collection."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if not hasattr(items, "__iter__"):
|
|
180
|
+
raise TypeError(
|
|
181
|
+
f"Iter source must be iterable, got {type(items).__name__}. "
|
|
182
|
+
f"Source: {self.source!r}. "
|
|
183
|
+
f"Expected a list, tuple, or other iterable collection."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# If no template and no x/y specified, return items as-is (passthrough mode)
|
|
187
|
+
if self.template is None and self.x is None:
|
|
188
|
+
return list(items)
|
|
189
|
+
|
|
190
|
+
results = []
|
|
191
|
+
for item in items:
|
|
192
|
+
# Create a new scope with the current item as the data context
|
|
193
|
+
item_scope = dict(scope) if scope else {}
|
|
194
|
+
# If item is a dict, add its keys to the scope for nested access
|
|
195
|
+
if isinstance(item, dict):
|
|
196
|
+
item_scope.update(item)
|
|
197
|
+
|
|
198
|
+
if self.template is not None:
|
|
199
|
+
# Template mode: resolve template against each item
|
|
200
|
+
result = resolve_value(self.template, item, item_scope)
|
|
201
|
+
results.append(result)
|
|
202
|
+
else:
|
|
203
|
+
# Coordinate mode: extract x, y, z, marker_size from each item
|
|
204
|
+
x_val = resolve_value(self.x, item, item_scope)
|
|
205
|
+
y_val = resolve_value(self.y, item, item_scope) if self.y else None
|
|
206
|
+
z_val = resolve_value(self.z, item, item_scope) if self.z else None
|
|
207
|
+
marker_size_val = resolve_value(self.marker_size, item, item_scope) if self.marker_size else None
|
|
208
|
+
|
|
209
|
+
# Build tuple based on what's present
|
|
210
|
+
if z_val is not None and marker_size_val is not None:
|
|
211
|
+
results.append((x_val, y_val, z_val, marker_size_val))
|
|
212
|
+
elif z_val is not None:
|
|
213
|
+
results.append((x_val, y_val, z_val))
|
|
214
|
+
elif marker_size_val is not None:
|
|
215
|
+
results.append((x_val, y_val, marker_size_val))
|
|
216
|
+
elif y_val is not None:
|
|
217
|
+
results.append((x_val, y_val))
|
|
218
|
+
else:
|
|
219
|
+
results.append(x_val)
|
|
220
|
+
|
|
221
|
+
return results
|
|
222
|
+
|
|
223
|
+
def __repr__(self) -> str:
|
|
224
|
+
parts = [f"source={self.source!r}"]
|
|
225
|
+
if self.template is not None:
|
|
226
|
+
parts.append(f"template={self.template!r}")
|
|
227
|
+
if self.x is not None:
|
|
228
|
+
parts.append(f"x={self.x!r}")
|
|
229
|
+
if self.y is not None:
|
|
230
|
+
parts.append(f"y={self.y!r}")
|
|
231
|
+
if self.z is not None:
|
|
232
|
+
parts.append(f"z={self.z!r}")
|
|
233
|
+
if self.marker_size is not None:
|
|
234
|
+
parts.append(f"marker_size={self.marker_size!r}")
|
|
235
|
+
return f"Iter({', '.join(parts)})"
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class Format(Spec):
|
|
240
|
+
"""Format a value using Python format specification.
|
|
241
|
+
|
|
242
|
+
Examples:
|
|
243
|
+
Format(Ref("value"), ".2f") -> f"{value:.2f}"
|
|
244
|
+
Format(Ref("pct"), ".1%") -> f"{pct:.1%}"
|
|
245
|
+
Format(Ref("num"), "04d") -> f"{num:04d}"
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
value: Spec | Any
|
|
249
|
+
fmt: str
|
|
250
|
+
|
|
251
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> str:
|
|
252
|
+
"""Resolve and format the value."""
|
|
253
|
+
val = resolve_value(self.value, data, scope)
|
|
254
|
+
result = format(val, self.fmt)
|
|
255
|
+
# Escape % from Python percentage formatting for LaTeX compatibility
|
|
256
|
+
if "%" in self.fmt:
|
|
257
|
+
result = result.replace("%", r"\%")
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
def __repr__(self) -> str:
|
|
261
|
+
return f'Format({self.value!r}, "{self.fmt}")'
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True)
|
|
265
|
+
class FormatNumber(Spec):
|
|
266
|
+
"""Format numbers with advanced options for significant digits, thousands separators, and more.
|
|
267
|
+
|
|
268
|
+
Handles the -0.00 case by removing the minus sign. Strings pass through unchanged by default.
|
|
269
|
+
|
|
270
|
+
Examples:
|
|
271
|
+
FormatNumber(Ref("value"), sig=2) -> "1.2" for 1.234
|
|
272
|
+
FormatNumber(Ref("value"), decimals=2) -> "1.23" for 1.234
|
|
273
|
+
FormatNumber(Ref("large_num"), thousands_sep=True) -> "2,000" for 2000
|
|
274
|
+
FormatNumber(Ref("value"), sig=2, thousands_sep=",") -> "1,200" for 1234
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
value: Spec | Any
|
|
278
|
+
sig: int | None = None # Significant digits
|
|
279
|
+
decimals: int | None = None # Fixed decimal places
|
|
280
|
+
thousands_sep: bool | str = False # True for comma, or custom separator
|
|
281
|
+
strip_negative_zero: bool = True # Remove minus sign from -0.00
|
|
282
|
+
|
|
283
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> str:
|
|
284
|
+
"""Resolve and format the value."""
|
|
285
|
+
val = resolve_value(self.value, data, scope)
|
|
286
|
+
|
|
287
|
+
# If it's a string and not a number, return as-is
|
|
288
|
+
if isinstance(val, str):
|
|
289
|
+
try:
|
|
290
|
+
val = float(val)
|
|
291
|
+
except (ValueError, TypeError):
|
|
292
|
+
return str(val)
|
|
293
|
+
|
|
294
|
+
# Try to convert to float for formatting
|
|
295
|
+
try:
|
|
296
|
+
num_val = float(val)
|
|
297
|
+
except (ValueError, TypeError):
|
|
298
|
+
# Not a number, return as string
|
|
299
|
+
return str(val)
|
|
300
|
+
|
|
301
|
+
# Format the number
|
|
302
|
+
if self.sig is not None and self.decimals is not None:
|
|
303
|
+
raise ValueError("Cannot specify both 'sig' and 'decimals' parameters")
|
|
304
|
+
|
|
305
|
+
if self.sig is not None:
|
|
306
|
+
# Use significant figures
|
|
307
|
+
if num_val == 0:
|
|
308
|
+
formatted = "0"
|
|
309
|
+
else:
|
|
310
|
+
# Use Python's g formatter for significant figures
|
|
311
|
+
format_str = f"{{:.{self.sig}g}}"
|
|
312
|
+
formatted = format_str.format(num_val)
|
|
313
|
+
elif self.decimals is not None:
|
|
314
|
+
# Use fixed decimal places
|
|
315
|
+
format_str = f"{{:.{self.decimals}f}}"
|
|
316
|
+
formatted = format_str.format(num_val)
|
|
317
|
+
else:
|
|
318
|
+
# Default: smart conversion - keep integers as integers
|
|
319
|
+
if num_val == int(num_val):
|
|
320
|
+
formatted = str(int(num_val))
|
|
321
|
+
else:
|
|
322
|
+
formatted = str(num_val)
|
|
323
|
+
|
|
324
|
+
# Strip negative zero if requested
|
|
325
|
+
if self.strip_negative_zero:
|
|
326
|
+
formatted = self._strip_negative_zero(formatted)
|
|
327
|
+
|
|
328
|
+
# Add thousands separator if requested
|
|
329
|
+
if self.thousands_sep:
|
|
330
|
+
separator = "," if self.thousands_sep is True else str(self.thousands_sep)
|
|
331
|
+
formatted = self._add_thousands_separator(formatted, separator)
|
|
332
|
+
|
|
333
|
+
return formatted
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def _strip_negative_zero(s: str) -> str:
|
|
337
|
+
"""Remove minus sign from negative zero values like -0.00."""
|
|
338
|
+
# Check if it's a negative zero
|
|
339
|
+
if s.startswith("-"):
|
|
340
|
+
# Try to parse it
|
|
341
|
+
try:
|
|
342
|
+
val = float(s)
|
|
343
|
+
if val == 0.0 or val == -0.0:
|
|
344
|
+
# It's negative zero, remove the minus
|
|
345
|
+
return s[1:]
|
|
346
|
+
except ValueError:
|
|
347
|
+
pass
|
|
348
|
+
return s
|
|
349
|
+
|
|
350
|
+
@staticmethod
|
|
351
|
+
def _add_thousands_separator(s: str, sep: str = ",") -> str:
|
|
352
|
+
"""Add thousands separator to a formatted number string."""
|
|
353
|
+
# Split on decimal point if present
|
|
354
|
+
if "." in s:
|
|
355
|
+
int_part, dec_part = s.split(".", 1)
|
|
356
|
+
dec_part = "." + dec_part
|
|
357
|
+
elif "e" in s.lower():
|
|
358
|
+
# Scientific notation, don't add separators
|
|
359
|
+
return s
|
|
360
|
+
else:
|
|
361
|
+
int_part = s
|
|
362
|
+
dec_part = ""
|
|
363
|
+
|
|
364
|
+
# Handle negative sign
|
|
365
|
+
if int_part.startswith("-"):
|
|
366
|
+
sign = "-"
|
|
367
|
+
int_part = int_part[1:]
|
|
368
|
+
else:
|
|
369
|
+
sign = ""
|
|
370
|
+
|
|
371
|
+
# Add thousands separators
|
|
372
|
+
# Reverse, group by 3, reverse back
|
|
373
|
+
reversed_int = int_part[::-1]
|
|
374
|
+
grouped = [reversed_int[i:i+3] for i in range(0, len(reversed_int), 3)]
|
|
375
|
+
int_part_with_sep = sep.join(grouped)[::-1]
|
|
376
|
+
|
|
377
|
+
return sign + int_part_with_sep + dec_part
|
|
378
|
+
|
|
379
|
+
def __repr__(self) -> str:
|
|
380
|
+
params = [f"{self.value!r}"]
|
|
381
|
+
if self.sig is not None:
|
|
382
|
+
params.append(f"sig={self.sig}")
|
|
383
|
+
if self.decimals is not None:
|
|
384
|
+
params.append(f"decimals={self.decimals}")
|
|
385
|
+
if self.thousands_sep:
|
|
386
|
+
params.append(f"thousands_sep={self.thousands_sep!r}")
|
|
387
|
+
if not self.strip_negative_zero:
|
|
388
|
+
params.append("strip_negative_zero=False")
|
|
389
|
+
return f"FormatNumber({', '.join(params)})"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True)
|
|
393
|
+
class Cond(Spec):
|
|
394
|
+
"""Conditional logic - returns one value or another based on condition.
|
|
395
|
+
|
|
396
|
+
Examples:
|
|
397
|
+
Cond(Ref("x") > 5, "red", "blue")
|
|
398
|
+
Cond(Ref("active"), "\\checkmark", "")
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
condition: Spec | bool
|
|
402
|
+
if_true: Any
|
|
403
|
+
if_false: Any = ""
|
|
404
|
+
|
|
405
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> Any:
|
|
406
|
+
"""Evaluate condition and return the selected branch (not resolved).
|
|
407
|
+
|
|
408
|
+
Note: This returns the branch itself, not its resolved value.
|
|
409
|
+
This allows Raw and other special specs to be handled properly
|
|
410
|
+
by the evaluation layer.
|
|
411
|
+
"""
|
|
412
|
+
cond_result = resolve_value(self.condition, data, scope)
|
|
413
|
+
if cond_result:
|
|
414
|
+
return self.if_true
|
|
415
|
+
return self.if_false
|
|
416
|
+
|
|
417
|
+
def __repr__(self) -> str:
|
|
418
|
+
return f"Cond({self.condition!r}, {self.if_true!r}, {self.if_false!r})"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@dataclass(frozen=True)
|
|
422
|
+
class Literal(Spec):
|
|
423
|
+
"""A literal value that doesn't need resolution.
|
|
424
|
+
|
|
425
|
+
Examples:
|
|
426
|
+
Literal("some text")
|
|
427
|
+
Literal(42)
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
value: Any
|
|
431
|
+
|
|
432
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> Any:
|
|
433
|
+
return self.value
|
|
434
|
+
|
|
435
|
+
def __repr__(self) -> str:
|
|
436
|
+
return f"Literal({self.value!r})"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@dataclass(frozen=True)
|
|
440
|
+
class Raw(Spec):
|
|
441
|
+
r"""Raw LaTeX code that should not be escaped.
|
|
442
|
+
|
|
443
|
+
Works universally in any context: as a plot item, row element, cell content, etc.
|
|
444
|
+
|
|
445
|
+
Examples:
|
|
446
|
+
Raw(r"\textbf{bold}")
|
|
447
|
+
Raw(r"\hline")
|
|
448
|
+
Raw(r"\draw (0,0) -- (1,1);") # In a plot
|
|
449
|
+
Raw(r"\cmidrule{2-4}") # In a table
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
latex: str
|
|
453
|
+
|
|
454
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> str:
|
|
455
|
+
return self.latex
|
|
456
|
+
|
|
457
|
+
def render(self, data: Any, scope: dict[str, Any] | None = None) -> str:
|
|
458
|
+
"""Render method for compatibility with Renderable protocol."""
|
|
459
|
+
return self.latex
|
|
460
|
+
|
|
461
|
+
def __repr__(self) -> str:
|
|
462
|
+
return f"Raw({self.latex!r})"
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def is_raw(self) -> bool:
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@dataclass(frozen=True)
|
|
470
|
+
class Call(Spec):
|
|
471
|
+
"""Call a function with resolved arguments.
|
|
472
|
+
|
|
473
|
+
Examples:
|
|
474
|
+
Call(len, Ref("items"))
|
|
475
|
+
Call(max, Ref("values"))
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
func: Callable[..., Any]
|
|
479
|
+
args: tuple[Any, ...] = field(default_factory=tuple)
|
|
480
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
481
|
+
|
|
482
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> Any:
|
|
483
|
+
resolved_args = [resolve_value(arg, data, scope) for arg in self.args]
|
|
484
|
+
resolved_kwargs = {
|
|
485
|
+
k: resolve_value(v, data, scope) for k, v in self.kwargs.items()
|
|
486
|
+
}
|
|
487
|
+
return self.func(*resolved_args, **resolved_kwargs)
|
|
488
|
+
|
|
489
|
+
def __repr__(self) -> str:
|
|
490
|
+
return f"Call({self.func.__name__}, {self.args!r}, {self.kwargs!r})"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@dataclass(frozen=True)
|
|
494
|
+
class Join(Spec):
|
|
495
|
+
"""Join multiple specs with a separator.
|
|
496
|
+
|
|
497
|
+
Examples:
|
|
498
|
+
Join([Ref("first"), Ref("last")], " ")
|
|
499
|
+
"""
|
|
500
|
+
|
|
501
|
+
parts: list[Any]
|
|
502
|
+
separator: str = ""
|
|
503
|
+
|
|
504
|
+
def resolve(self, data: Any, scope: dict[str, Any] | None = None) -> str:
|
|
505
|
+
resolved = [str(resolve_value(p, data, scope)) for p in self.parts]
|
|
506
|
+
return self.separator.join(resolved)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def resolve_value(value: Any, data: Any, scope: dict[str, Any] | None = None) -> Any:
|
|
510
|
+
"""Resolve a value which may be a Spec or a plain value."""
|
|
511
|
+
if isinstance(value, Spec):
|
|
512
|
+
return value.resolve(data, scope)
|
|
513
|
+
return value
|