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/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