texmodel 0.2.0__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.
Files changed (61) hide show
  1. texmodel/__init__.py +6 -0
  2. texmodel/_internal/__init__.py +1 -0
  3. texmodel/_internal/render.py +323 -0
  4. texmodel/_internal/scan.py +23 -0
  5. texmodel/body.py +364 -0
  6. texmodel/bundle.py +69 -0
  7. texmodel/cli.py +211 -0
  8. texmodel/components.py +757 -0
  9. texmodel/config.py +32 -0
  10. texmodel/drift.py +55 -0
  11. texmodel/errors.py +38 -0
  12. texmodel/package.py +482 -0
  13. texmodel/plan.py +92 -0
  14. texmodel/pointer.py +70 -0
  15. texmodel/preamble.py +110 -0
  16. texmodel/py.typed +0 -0
  17. texmodel/spec.py +235 -0
  18. texmodel/std/__init__.py +37 -0
  19. texmodel/std/adjustbox.py +19 -0
  20. texmodel/std/anyfontsize.py +7 -0
  21. texmodel/std/calc.py +7 -0
  22. texmodel/std/enumitem.py +18 -0
  23. texmodel/std/eso_pic.py +49 -0
  24. texmodel/std/etoolbox.py +127 -0
  25. texmodel/std/fontspec.py +36 -0
  26. texmodel/std/geometry.py +17 -0
  27. texmodel/std/graphicx.py +26 -0
  28. texmodel/std/hyphenat.py +7 -0
  29. texmodel/std/latex.py +516 -0
  30. texmodel/std/microtype.py +27 -0
  31. texmodel/std/pdfrender.py +17 -0
  32. texmodel/std/tcolorbox.py +43 -0
  33. texmodel/std/tikz.py +36 -0
  34. texmodel/std/xcolor.py +47 -0
  35. texmodel/testing.py +297 -0
  36. texmodel/types.py +264 -0
  37. texmodel-0.2.0.dist-info/METADATA +525 -0
  38. texmodel-0.2.0.dist-info/RECORD +61 -0
  39. texmodel-0.2.0.dist-info/WHEEL +4 -0
  40. texmodel-0.2.0.dist-info/entry_points.txt +9 -0
  41. texmodel-0.2.0.dist-info/licenses/LICENSE +21 -0
  42. texmodel_build/__init__.py +10 -0
  43. texmodel_build/_internal/__init__.py +1 -0
  44. texmodel_build/_internal/converge.py +19 -0
  45. texmodel_build/_internal/env.py +42 -0
  46. texmodel_build/_internal/logparse.py +170 -0
  47. texmodel_build/_internal/proofpdf.py +52 -0
  48. texmodel_build/_internal/spawn.py +36 -0
  49. texmodel_build/_internal/styscan.py +37 -0
  50. texmodel_build/cli.py +349 -0
  51. texmodel_build/compiler.py +341 -0
  52. texmodel_build/config.py +58 -0
  53. texmodel_build/diagnostics.py +225 -0
  54. texmodel_build/engines.py +35 -0
  55. texmodel_build/errors.py +17 -0
  56. texmodel_build/py.typed +0 -0
  57. texmodel_build/result.py +65 -0
  58. texmodel_build/roots.py +41 -0
  59. texmodel_build/targets.py +43 -0
  60. texmodel_build/testkit.py +59 -0
  61. texmodel_build/verify.py +158 -0
texmodel/components.py ADDED
@@ -0,0 +1,757 @@
1
+ r"""Components (M1): a `Command` is declared as a class; its fields are its arguments, its docstring its documentation.
2
+
3
+ ```python
4
+ pkg = Package("acme-cards", prefix="ac")
5
+
6
+ @pkg.command
7
+ class Slot(Command):
8
+ '''A labelled paragraph; the label prints as its heading.'''
9
+ text: Text
10
+ label: Text | None = None # optional: keyword-only, rendered as TeX's one optional argument
11
+ ```
12
+
13
+ A `Value[T]` is a typed setting; its `default` is an instance of `T` and its type decides the TeX (M1a, T4, T5):
14
+
15
+ ```python
16
+ @pkg.value
17
+ class FactGap(Value[Length]):
18
+ '''Space under each fact.'''
19
+ default = Length("2mm")
20
+ ```
21
+
22
+ An `Environment` also holds children, whose order and counts are its grammar (M4); a Doc is plain Python (D1):
23
+
24
+ ```python
25
+ @pkg.environment
26
+ class CardBack(Environment):
27
+ '''The back face of a card.'''
28
+ children = [Name, Fact.times(2, 3), Slot.times(3)] # Name: one; Fact: 2 to 3; Slot: 0 to 3
29
+ # also `X.times.any()` (0 or more) and `X.times.at_least_one()` (1 or more)
30
+ begin = r'\begin{minipage}{\linewidth}'
31
+ end = r'\end{minipage}'
32
+
33
+ pkg.document(CardBack(Name("Rex"), Fact("Friendly & loyal."), Fact("50% of $5")), options=["a4paper"])
34
+ ```
35
+
36
+ `Text` fields are escaped on render (T3); a `Raw` field passes TeX through.
37
+
38
+ A body is TeX, given explicitly: a `body` method returning it (a plain `body = r"..."` string works too), or a
39
+ `TemplateRef` to a file. Indentation is removed, so a triple-quoted raw string reads as it will render:
40
+
41
+ ```python
42
+ class Title(Command):
43
+ '''A job title.'''
44
+ text: Text
45
+
46
+ def body(self):
47
+ return r'''
48
+ \par\noindent{\color{bc-accent}#text}\par
49
+ '''
50
+
51
+ class Slot(Command):
52
+ '''A labelled paragraph.'''
53
+ text: Text
54
+
55
+ def body(self):
56
+ return TemplateRef("slot.tex") # relative to this module; nothing is looked up by name
57
+ ```
58
+
59
+ An environment has `begin` and `end` in the same way. The method is read once, when the class is defined, with no
60
+ instance: keep it to constant TeX. Inside a body `#text` is the argument named `text`. A bad declaration is refused when
61
+ the class is defined.
62
+ """
63
+
64
+ import dataclasses
65
+ import inspect
66
+ import sys
67
+ import types
68
+ import typing
69
+ from collections.abc import Sequence
70
+ from typing import Any, ClassVar, dataclass_transform, overload
71
+
72
+ from ._internal.render import (
73
+ Arg,
74
+ EnvSpec,
75
+ Spec,
76
+ ValueSpec,
77
+ arguments,
78
+ check_name,
79
+ )
80
+ from .body import (
81
+ Comment,
82
+ Cs,
83
+ Group,
84
+ Hole,
85
+ Library,
86
+ Lit,
87
+ Ref,
88
+ RefKind,
89
+ Seq,
90
+ Syntax,
91
+ TemplateRef,
92
+ TexRaw,
93
+ Whitespace,
94
+ join,
95
+ pieces_of,
96
+ refs_in,
97
+ syntaxes_in,
98
+ template_text,
99
+ )
100
+ from .errors import DeclarationError, Violation
101
+ from .types import CMYK, PDF, RGB, TEXT, Choice, Colour, Gray, Length, Raw, Text
102
+
103
+ type Node = Command | Environment | Passthrough | Comment
104
+ Resolved = TexRaw | Ref | Comment | Group | Seq | Cs | Lit | Hole
105
+ Source = tuple[tuple[Resolved, ...], str] # a body's pieces and where they came from
106
+
107
+
108
+ @dataclasses.dataclass(frozen=True)
109
+ class Repeat:
110
+ """How many of one component a grammar allows in a row (M4): `Fact.times(2, 3)`; a bare `Name` is exactly one."""
111
+
112
+ kind: type
113
+ lo: int
114
+ hi: int | None
115
+
116
+ def __str__(self) -> str:
117
+ return f"{self.lo}-{'any' if self.hi is None else self.hi} {self.kind.__name__}"
118
+
119
+
120
+ @dataclasses.dataclass(frozen=True)
121
+ class Anywhere:
122
+ """A grammar term (B3): zero or more `kind` between any two other terms and at both ends, in any number of
123
+ places: `children = [Name, Anywhere(Heading), Fact.times(2, 3)]`. The kind may not also be an ordered term."""
124
+
125
+ kind: type
126
+
127
+ def __str__(self) -> str:
128
+ return f"{self.kind.__name__} anywhere"
129
+
130
+
131
+ class _Times:
132
+ """`Fact.times(2, 3)`, `Fact.times(3)` (0 to 3), `Fact.times(2, None)` (2 or more), `.any()`, `.at_least_one()`."""
133
+
134
+ def __init__(self, kind: type) -> None:
135
+ self.kind = kind
136
+
137
+ @overload
138
+ def __call__(self, to: int | None, /) -> Repeat: ...
139
+ @overload
140
+ def __call__(self, from_: int, to: int | None, /) -> Repeat: ...
141
+ def __call__(self, *bounds: int | None) -> Repeat:
142
+ """`times(to)` allows 0 to `to`; `times(from_, to)` allows `from_` to `to`; `to=None` has no upper limit."""
143
+ lo, hi = (0, bounds[0]) if len(bounds) == 1 else bounds if len(bounds) == 2 else (None, None)
144
+ # `type(...) is int` also turns away floats and bools, which slip past the annotations at runtime.
145
+ if type(lo) is not int or lo < 0 or not (hi is None or type(hi) is int and hi >= max(lo, 1)):
146
+ raise DeclarationError(
147
+ f"{self.kind.__name__}.times{bounds}: use (to) or (from, to), ints with 0 <= from <= to and 1 <= to; "
148
+ "to=None means no limit",
149
+ code="TM-M020",
150
+ path=self.kind.__name__,
151
+ )
152
+ return Repeat(self.kind, lo, hi)
153
+
154
+ def any(self) -> Repeat:
155
+ """Zero or more."""
156
+ return Repeat(self.kind, 0, None)
157
+
158
+ def at_least_one(self) -> Repeat:
159
+ """One or more."""
160
+ return Repeat(self.kind, 1, None)
161
+
162
+
163
+ class _Meta(type):
164
+ """Gives every component class `.times`, so a grammar term reads `Fact.times(2, 3)`."""
165
+
166
+ @property
167
+ def times(cls) -> _Times:
168
+ return _Times(cls)
169
+
170
+
171
+ def _document(cls: type) -> str:
172
+ check_name(cls.__name__, "component")
173
+ doc = (cls.__dict__.get("__doc__") or "").strip()
174
+ if not doc:
175
+ raise DeclarationError(f"{cls.__qualname__}: document the component in its docstring", code="TM-M002")
176
+ return doc
177
+
178
+
179
+ def _source(cls: type, name: str) -> Source | None:
180
+ """The class's own `body` / `begin` / `end` as (pieces, origin), or None.
181
+
182
+ It is a string, a `TemplateRef` (the file is read now), a `TexRaw`, a `Ref`, a `Comment`, a sequence, iterator or
183
+ generator of those, or a method returning any of them. A field of that name is not a body.
184
+ """
185
+ found: object = cls.__dict__.get(name)
186
+ if found is None or name in inspect.get_annotations(cls):
187
+ return None
188
+ if isinstance(found, types.FunctionType):
189
+ try:
190
+ found = found(None)
191
+ except (AttributeError, TypeError) as exc:
192
+ raise DeclarationError(
193
+ f"{cls.__qualname__}.{name}(): {exc}",
194
+ code="TM-M022",
195
+ path=cls.__name__,
196
+ hint="it is read when the class is defined, so it cannot use self: return constant TeX",
197
+ ) from exc
198
+ if (given := pieces_of(found)) is None:
199
+ got = type(found).__name__
200
+ raise DeclarationError(
201
+ f"{cls.__qualname__}.{name}: must be TeX (a string, a TemplateRef, a TexRaw, a Ref, a Comment, a Group, "
202
+ f"Seq, Cs, Lit or Hole, a sequence of them, or a method returning one), not {got}",
203
+ code="TM-M021",
204
+ path=cls.__name__,
205
+ )
206
+ origins: list[str] = []
207
+ out: list[Resolved] = []
208
+ for piece in given:
209
+ if isinstance(piece, TemplateRef):
210
+ text = template_text(piece, sys.modules[cls.__module__].__file__ or ".", f"{cls.__qualname__}.{name}")
211
+ out.append(TexRaw(text, piece.whitespace, piece.syntax))
212
+ origins.append(piece.path.name)
213
+ else:
214
+ out.append(TexRaw(piece) if isinstance(piece, str) else piece)
215
+ origins.append("inline")
216
+ return tuple(out), " + ".join(dict.fromkeys(origins))
217
+
218
+
219
+ def control_name(cls: type) -> str:
220
+ """The control sequence of a registered component, without the backslash: its `cs`, else its package's prefix and
221
+ its class name."""
222
+ name: str | None = getattr(cls, "__tm_cs__", None)
223
+ if name is None:
224
+ raise DeclarationError(
225
+ f"{cls.__qualname__}: not registered with a package", code="TM-M019", hint="decorate it with @pkg.command"
226
+ )
227
+ return name
228
+
229
+
230
+ class _Node(metaclass=_Meta):
231
+ """What a `Command` and an `Environment` share: declaration data, `repr`, `validate` and `render`."""
232
+
233
+ __tm_unused__: ClassVar[tuple[str, ...]]
234
+ __tm_args__: ClassVar[tuple[Arg, ...]]
235
+ __tm_doc__: ClassVar[str]
236
+ __tm_prefix__: ClassVar[str] # set when a package registers the class
237
+ __tm_cs__: ClassVar[str] # likewise: the control sequence, see `control_name`
238
+ cs: ClassVar[str | None] = None # the TeX name, when it is not prefix + class name (`cs = "includegraphics"`)
239
+ syntax: ClassVar[Syntax] = Syntax.LATEX2E # the names `cs` may have: `Syntax.EXPL3` allows `_` and `:`
240
+ defined_by: ClassVar[Library | None] = None # the library block that defines it, so it renders no definition
241
+ __tm_package__: ClassVar[object] # the `Package` it was registered with
242
+
243
+ def __repr__(self) -> str:
244
+ return _repr(self) # pyright: ignore[reportArgumentType]
245
+
246
+ def validate(self) -> list[Violation]:
247
+ """Every broken rule at once (E2), with its path into the tree: argument types and, for an environment, the
248
+ children against the grammar (M4)."""
249
+ return _violations(self, type(self).__name__) # pyright: ignore[reportArgumentType]
250
+
251
+ def render(self) -> str:
252
+ """The TeX for this node (`\\acFact{...}`, or `\\begin{acCardBack}` ... `\\end{acCardBack}`): pure and
253
+ deterministic (D2). Raises an `ExceptionGroup` if the node is invalid."""
254
+ return _render(self) # pyright: ignore[reportArgumentType]
255
+
256
+
257
+ def _as_field(given: object) -> "dataclasses.Field[object]":
258
+ """A default or a `field(...)` as a dataclass field."""
259
+ if isinstance(given, dataclasses.Field):
260
+ return typing.cast("dataclasses.Field[object]", given)
261
+ return typing.cast("dataclasses.Field[object]", dataclasses.field(default=given))
262
+
263
+
264
+ def _build(cls: type[_Node], unused: Sequence[str], *, init: bool) -> None:
265
+ """Turn a declared class into a frozen dataclass whose fields are its arguments."""
266
+ cls.__tm_doc__ = doc = _document(cls)
267
+ cls.__tm_unused__ = tuple(unused)
268
+ if "cs" in cls.__dict__:
269
+ Cs(cls.__dict__["cs"], cls.syntax) # a control word of the class's syntax (TM-M027)
270
+ # Optional fields are keyword-only (M2): mark them before the dataclass is built.
271
+ for name in inspect.get_annotations(cls):
272
+ if name in cls.__dict__ and not name.startswith("__"):
273
+ spec = _as_field(cls.__dict__[name])
274
+ if spec.default is not dataclasses.MISSING:
275
+ spec.kw_only = True
276
+ setattr(cls, name, spec)
277
+ dataclasses.dataclass(frozen=True, init=init, repr=False)(cls)
278
+ cls.__doc__ = doc
279
+ cls.__tm_args__ = _arguments(cls) # pyright: ignore[reportArgumentType]
280
+
281
+
282
+ def field(*, renamed_from: str, default: Any = dataclasses.MISSING) -> Any: # noqa: ANN401 # as dataclasses.field
283
+ """A field that data (`from_dict`) may still name `renamed_from`: `to_dict` and the schema use the field's name, the
284
+ old key loads, and the schema records it as `x-renamed-from` (§21a 15). `default` makes it optional."""
285
+ return dataclasses.field(default=default, metadata={"renamed_from": renamed_from})
286
+
287
+
288
+ @dataclass_transform(frozen_default=True, field_specifiers=(field,))
289
+ class Command(_Node):
290
+ """A TeX command (`\\newcommand`) whose arguments are the class's fields."""
291
+
292
+ __tm_inline__: ClassVar[Source | None]
293
+
294
+ def __init_subclass__(cls, *, unused: Sequence[str] = (), **kwargs: Any) -> None:
295
+ super().__init_subclass__(**kwargs)
296
+ cls.__tm_inline__ = _source(cls, "body")
297
+ _build(cls, unused, init=True)
298
+
299
+ @classmethod
300
+ def call(cls, *required: ArgValue, **optional: ArgValue) -> Ref:
301
+ """A body piece that calls this command (§21a 17): the required arguments positionally, the optional one by
302
+ keyword, each a `Hole` (an argument of the component being defined), a `Raw`, a `str` (escaped) or a `Choice`.
303
+ """
304
+ return _ref(cls, RefKind.CALL, required, optional)
305
+
306
+
307
+ @dataclasses.dataclass(frozen=True, init=False, repr=False)
308
+ class Environment(_Node):
309
+ """A TeX environment (`\\newenvironment`): fields are its arguments, positional arguments its children."""
310
+
311
+ content: tuple[Node, ...]
312
+ children: ClassVar[Sequence[type | Repeat | Anywhere]] = () # the grammar: components in order, with counts (M4)
313
+ __tm_inline__: ClassVar[tuple[Source | None, Source | None]]
314
+ __tm_grammar__: ClassVar[tuple[Repeat | Anywhere, ...]]
315
+
316
+ def __init_subclass__(cls, *, unused: Sequence[str] = (), **kwargs: Any) -> None:
317
+ super().__init_subclass__(**kwargs)
318
+ cls.__tm_inline__ = (_source(cls, "begin"), _source(cls, "end"))
319
+ cls.__tm_grammar__ = _grammar(cls)
320
+ _build(cls, unused, init=False)
321
+
322
+ @classmethod
323
+ def open(cls, **args: ArgValue) -> Ref:
324
+ """A body piece that starts this environment, `\\begin{acBox}`, with its arguments by keyword (§21a 17)."""
325
+ return _ref(cls, RefKind.OPEN, (), args)
326
+
327
+ @classmethod
328
+ def close(cls) -> Ref:
329
+ """A body piece that ends this environment, `\\end{acBox}` (§21a 17)."""
330
+ return _ref(cls, RefKind.CLOSE, (), {})
331
+
332
+ def __init__(self, *content: Node, **args: str | Raw | None) -> None:
333
+ fields = {f.name: f for f in dataclasses.fields(self) if f.name != "content"}
334
+ if unknown := sorted(set(args) - set(fields)):
335
+ raise TypeError(f"{type(self).__name__}() got unexpected keyword arguments {unknown}")
336
+ if missing := sorted(n for n, f in fields.items() if f.default is dataclasses.MISSING and n not in args):
337
+ raise TypeError(f"{type(self).__name__}() missing keyword arguments {missing}")
338
+ object.__setattr__(self, "content", content)
339
+ for name, f in fields.items():
340
+ object.__setattr__(self, name, args.get(name, f.default))
341
+
342
+
343
+ @dataclasses.dataclass(frozen=True, repr=False)
344
+ class Passthrough(_Node):
345
+ """TeX written as it is (B3): a layout `\\vspace` between components. It claims no control sequence; a grammar must
346
+ list it (`Passthrough.times.any()`, or `Anywhere(Passthrough)`) before an environment accepts one."""
347
+
348
+ tex: Raw
349
+ __tm_args__: ClassVar[tuple[Arg, ...]] = (Arg("tex", raw=True),)
350
+ __tm_doc__: ClassVar[str] = "TeX written as it is."
351
+
352
+
353
+ type ArgValue = Hole | Raw | str | Choice
354
+
355
+
356
+ def _ref(cls: type, kind: RefKind, required: tuple[ArgValue, ...], optional: dict[str, ArgValue]) -> Ref:
357
+ """Check an argument list against the component's arguments and keep it in declared order (`TM-M026`)."""
358
+ declared = typing.cast("type[Command] | type[Environment]", cls).__tm_args__
359
+ positional = [a for a in declared if not a.optional and not issubclass(cls, Environment)]
360
+ allowed = {a.name for a in declared} - {a.name for a in positional}
361
+ values = {**dict(zip((a.name for a in positional), required, strict=False)), **optional}
362
+ problems = (
363
+ [f"{len(positional)} positional argument(s), got {len(required)}"] if len(required) != len(positional) else []
364
+ )
365
+ problems += [f"unknown argument {n!r}" for n in sorted(set(optional) - allowed)]
366
+ problems += [f"missing argument {a.name!r}" for a in declared if not a.optional and a.name not in values]
367
+ for a in declared:
368
+ value = values.get(a.name)
369
+ if value is not None and not isinstance(value, Hole | (a.choice or (Raw if a.raw else str))):
370
+ problems.append(
371
+ f"{a.name!r} must be a Hole or a {'Choice member' if a.choice else 'Raw' if a.raw else 'str'}"
372
+ )
373
+ if problems and kind is not RefKind.CLOSE:
374
+ raise DeclarationError(
375
+ f"{cls.__name__}.{kind.value}(): " + "; ".join(problems),
376
+ code="TM-M026",
377
+ path=cls.__name__,
378
+ )
379
+ return Ref(cls, kind, tuple(values.get(a.name) for a in declared))
380
+
381
+
382
+ def _ref_text(ref: Ref, owner: str, syntax: Syntax) -> str:
383
+ """The TeX a reference stands for. The target must be registered: its prefix is part of the name."""
384
+ if not hasattr(ref.target, "__tm_cs__"):
385
+ raise DeclarationError(
386
+ f"{owner}: calls {ref.target.__name__}, which is not registered with a package",
387
+ code="TM-M019",
388
+ path=owner,
389
+ hint="register it (decorate it with its package's command/environment) before the body that calls it",
390
+ )
391
+ name = control_name(ref.target)
392
+ declared = typing.cast("type[Command] | type[Environment]", ref.target).__tm_args__
393
+ args = arguments(declared, {a.name: v for a, v in zip(declared, ref.values, strict=True)}, syntax)
394
+ return {
395
+ RefKind.CALL: f"\\{name}{args}",
396
+ RefKind.OPEN: f"\\begin{{{name}}}{args}",
397
+ RefKind.CLOSE: f"\\end{{{name}}}",
398
+ }[ref.kind]
399
+
400
+
401
+ def _repr(node: Command | Environment | Passthrough) -> str:
402
+ """The constructor expression that rebuilds the node (D1): required fields positional, optional ones by keyword."""
403
+ shown = [repr(c) for c in node.content] if isinstance(node, Environment) else []
404
+ for a in type(node).__tm_args__:
405
+ v = getattr(node, a.name)
406
+ if a.optional and v is not None:
407
+ shown.append(f"{a.name}={v!r}")
408
+ elif not a.optional and not isinstance(node, Environment):
409
+ shown.append(repr(v))
410
+ return f"{type(node).__name__}({', '.join(shown)})"
411
+
412
+
413
+ def _grammar(cls: type[Environment]) -> tuple[Repeat | Anywhere, ...]:
414
+ terms: list[Repeat | Anywhere] = []
415
+ for item in cls.__dict__.get("children", ()):
416
+ if isinstance(item, type) and issubclass(item, Command | Environment | Passthrough):
417
+ item = Repeat(item, 1, 1)
418
+ if not isinstance(item, Repeat | Anywhere) or not issubclass(item.kind, _Node):
419
+ raise DeclarationError(
420
+ f"{cls.__qualname__}: children must be components, optionally multiplied; got {item!r}",
421
+ code="TM-M020",
422
+ path=cls.__name__,
423
+ )
424
+ terms.append(item)
425
+ floating = {t.kind for t in terms if isinstance(t, Anywhere)}
426
+ if both := sorted(t.kind.__name__ for t in terms if isinstance(t, Repeat) and t.kind in floating):
427
+ raise DeclarationError(
428
+ f"{cls.__qualname__}: {both} is both an ordered term and Anywhere",
429
+ code="TM-M029",
430
+ path=cls.__name__,
431
+ hint="a kind is one or the other",
432
+ )
433
+ return tuple(terms)
434
+
435
+
436
+ def _reserved_type(cls: type) -> DeclarationError:
437
+ return DeclarationError(
438
+ f'{cls.__qualname__}.type: `type` names the component in data (`"type": "Fact"`), so it cannot be a field',
439
+ code="TM-M030",
440
+ path=f"{cls.__name__}.type",
441
+ hint="call the field something else, e.g. `kind`",
442
+ )
443
+
444
+
445
+ def _arguments(cls: type[Command] | type[Environment]) -> tuple[Arg, ...]:
446
+ hints = typing.get_type_hints(cls, include_extras=True)
447
+ args: list[Arg] = []
448
+ for f in dataclasses.fields(cls): # pyright: ignore[reportArgumentType]
449
+ if f.name == "content":
450
+ continue
451
+ if f.name == "type":
452
+ raise _reserved_type(cls)
453
+ kind = _kind(hints[f.name])
454
+ if kind is None:
455
+ raise DeclarationError(
456
+ f"{cls.__qualname__}.{f.name}: unsupported type {hints[f.name]!r}; "
457
+ "use Text, PdfString, Raw or a Choice, or one of them | None",
458
+ code="TM-M004",
459
+ path=f"{cls.__name__}.{f.name}",
460
+ )
461
+ choice = _choice(cls, f.name, kind) if isinstance(kind, type) else None
462
+ args.append(
463
+ Arg(
464
+ f.name,
465
+ optional=f.default is not dataclasses.MISSING,
466
+ raw=kind == "raw",
467
+ choice=choice,
468
+ pdf=kind == "pdf",
469
+ renamed_from=f.metadata.get("renamed_from"),
470
+ )
471
+ )
472
+ if sum(a.optional for a in args) > 1:
473
+ raise DeclarationError(
474
+ f"{cls.__qualname__}: more than one optional argument (TeX's \\newcommand allows one)",
475
+ code="TM-M003",
476
+ path=cls.__name__,
477
+ )
478
+ return tuple(args)
479
+
480
+
481
+ def _choice(cls: type, field: str, kind: type[Choice]) -> type[Choice]:
482
+ """A `Choice` takes TeX from its members' values: all `Raw` or all `str`, and no two members alike (aliases)."""
483
+ values = [m.value for m in kind]
484
+ if len(values) != len(kind.__members__) or not (
485
+ all(isinstance(v, Raw) for v in values) or all(isinstance(v, str) for v in values)
486
+ ):
487
+ raise DeclarationError(
488
+ f"{cls.__qualname__}.{field}: {kind.__name__} must have members that are all Raw or all str, none an alias",
489
+ code="TM-M024",
490
+ path=f"{cls.__name__}.{field}",
491
+ hint="give each member its TeX as a Raw (passes through) or a str (escaped)",
492
+ )
493
+ return kind
494
+
495
+
496
+ def _kind(hint: Any) -> str | type[Choice] | None:
497
+ """`"text"` for `Text` / `Text | None`, `"raw"` for `Raw` / `Raw | None`, the class for a `Choice` (or `| None`)."""
498
+ members = [
499
+ m for m in (typing.get_args(hint) if isinstance(hint, types.UnionType) else (hint,)) if m is not type(None)
500
+ ]
501
+ if members and all(typing.get_origin(m) is typing.Annotated and TEXT in typing.get_args(m)[1:] for m in members):
502
+ return "text"
503
+ if members and all(typing.get_origin(m) is typing.Annotated and PDF in typing.get_args(m)[1:] for m in members):
504
+ return "pdf"
505
+ if len(members) == 1 and isinstance(members[0], type) and issubclass(members[0], Choice):
506
+ return members[0]
507
+ return "raw" if members and all(m is Raw for m in members) else None
508
+
509
+
510
+ def _violations(node: Command | Environment | Passthrough, path: str) -> list[Violation]:
511
+ cls = type(node)
512
+ out: list[Violation] = []
513
+ for a in cls.__tm_args__:
514
+ v = getattr(node, a.name)
515
+ if not (v is None and a.optional or isinstance(v, a.choice or (Raw if a.raw else str))):
516
+ want = (
517
+ f"one of {list(a.choice.__members__)}"
518
+ if a.choice
519
+ else "Raw"
520
+ if a.raw
521
+ else "PdfString"
522
+ if a.pdf
523
+ else "Text"
524
+ )
525
+ out.append(Violation("TM-C003", f"{path}.{a.name}", f"{a.name}: expected {want}, got {v!r}"))
526
+ if isinstance(node, Environment):
527
+ out += _grammar_violations(node, path)
528
+ for i, child in enumerate(node.content):
529
+ if isinstance(child, Command | Environment | Passthrough): # pyright: ignore[reportUnnecessaryIsInstance]
530
+ out += _violations(child, f"{path}/{type(child).__name__}[{i}]")
531
+ return out
532
+
533
+
534
+ def _grammar_violations(node: Environment, path: str) -> list[Violation]:
535
+ """Match the children against the grammar: each term takes its run of that component, in order. A `Comment` and
536
+ a child of an `Anywhere` kind are skipped (B3), and every path keeps the child's real position."""
537
+ grammar = type(node).__tm_grammar__
538
+ floating = {t.kind for t in grammar if isinstance(t, Anywhere)}
539
+ ordered = [t for t in grammar if isinstance(t, Repeat)]
540
+ content = [(j, c) for j, c in enumerate(node.content) if not isinstance(c, Comment) and type(c) not in floating]
541
+ allowed = ", ".join(map(str, grammar)) or "nothing"
542
+ out: list[Violation] = []
543
+ i = 0
544
+ for term in ordered:
545
+ n = 0
546
+ while i < len(content) and (term.hi is None or n < term.hi) and type(content[i][1]) is term.kind:
547
+ i, n = i + 1, n + 1
548
+ if n < term.lo:
549
+ out.append(
550
+ Violation(
551
+ "TM-C002", path, f"{type(node).__name__}: expected {term}, found {n}", hint=f"allowed: {allowed}"
552
+ )
553
+ )
554
+ for j, child in content[i:]:
555
+ found = type(child).__name__
556
+ out.append(
557
+ Violation(
558
+ "TM-C001",
559
+ f"{path}/{found}[{j}]",
560
+ f"{type(node).__name__}: {found} is not allowed here",
561
+ hint=f"allowed, in order: {allowed}",
562
+ )
563
+ )
564
+ return out
565
+
566
+
567
+ def _render(node: Command | Environment | Passthrough) -> str:
568
+ if violations := _violations(node, type(node).__name__):
569
+ raise ExceptionGroup(f"invalid {type(node).__name__}", [v.error() for v in violations])
570
+ return _emit(node)
571
+
572
+
573
+ def _emit(node: Node) -> str:
574
+ if isinstance(node, Comment):
575
+ return node.render()
576
+ if isinstance(node, Passthrough):
577
+ return node.tex.tex
578
+ cls = type(node)
579
+ name = control_name(cls)
580
+ args = arguments(cls.__tm_args__, {a.name: getattr(node, a.name) for a in cls.__tm_args__})
581
+ if isinstance(node, Command):
582
+ return f"\\{name}{args}"
583
+ return "\n".join([f"\\begin{{{name}}}{args}", *map(_emit, node.content), f"\\end{{{name}}}"])
584
+
585
+
586
+ def _syntax(cls: type, *sources: Source | None) -> Syntax:
587
+ """The one syntax of a body, or of an environment's two (`TM-M031` if its pieces differ); `LATEX2E` if none says."""
588
+ found = dict.fromkeys(s for src in sources if src for piece in src[0] for s in syntaxes_in(piece))
589
+ if len(found) > 1:
590
+ raise DeclarationError(
591
+ f"{cls.__qualname__}: the body mixes {' and '.join(s.value for s in found)}",
592
+ code="TM-M031",
593
+ path=cls.__name__,
594
+ hint="a body is written in one syntax: give every TexRaw, TemplateRef and Cs the same `syntax=`",
595
+ )
596
+ return next(iter(found), Syntax.LATEX2E)
597
+
598
+
599
+ def _required(
600
+ cls: type, found: Source | None, option: str, default: Whitespace, syntax: Syntax
601
+ ) -> tuple[str, str, tuple[type, ...]]:
602
+ """The text of a body (the package's default whitespace policy for pieces that have none), its origin and the
603
+ components it calls."""
604
+ if found is None:
605
+ raise DeclarationError(
606
+ f"{cls.__module__}.{cls.__qualname__}: no {option}",
607
+ code="TM-M006",
608
+ path=cls.__name__,
609
+ hint=f'define `{option}` as a string, a TemplateRef("file.tex"), a TexRaw, or a method returning one',
610
+ )
611
+ owner = f"{cls.__qualname__}.{option}"
612
+ text = join(found[0], default, owner, lambda ref: _ref_text(ref, owner, syntax), syntax)
613
+ return text, found[1], tuple(dict.fromkeys(r.target for p in found[0] for r in refs_in(p)))
614
+
615
+
616
+ def _declared_only(cls: "type[Command] | type[Environment]", *sources: Source | None) -> None:
617
+ """A component of an adopted package (M9) or one a library block defines (§21a 12) is only declared: a body would
618
+ never render, so it is refused."""
619
+ if any(sources) and cls.defined_by is not None:
620
+ raise DeclarationError(
621
+ f"{cls.__module__}.{cls.__qualname__}: has a body, but a library block defines it",
622
+ code="TM-M032",
623
+ path=cls.__name__,
624
+ hint="remove the body: the block is the definition",
625
+ )
626
+ if any(sources):
627
+ raise DeclarationError(
628
+ f"{cls.__module__}.{cls.__qualname__}: has a body, but its package is adopted and renders no TeX",
629
+ code="TM-M023",
630
+ path=cls.__name__,
631
+ hint="remove the body: the hand-written package defines it",
632
+ )
633
+
634
+
635
+ def spec_of(cls: type[Command], *, declared_only: bool = False, whitespace: Whitespace = Whitespace.KEEP) -> Spec:
636
+ """The declaration as the renderer sees it, with its body (M8); `declared_only` (M9) has none."""
637
+ declared_only = declared_only or cls.defined_by is not None
638
+ if declared_only:
639
+ _declared_only(cls, cls.__tm_inline__)
640
+ syntax = _syntax(cls, cls.__tm_inline__)
641
+ body, origin, refs = (
642
+ ("", "declared", ()) if declared_only else _required(cls, cls.__tm_inline__, "body", whitespace, syntax)
643
+ )
644
+ return Spec(
645
+ cls.__name__,
646
+ cls.__tm_doc__,
647
+ cls.__tm_args__,
648
+ body,
649
+ origin,
650
+ f"{cls.__module__}.{cls.__qualname__}",
651
+ cls.__tm_unused__,
652
+ refs,
653
+ cls.__dict__.get("cs"),
654
+ syntax,
655
+ )
656
+
657
+
658
+ def env_spec_of(
659
+ cls: type[Environment], *, declared_only: bool = False, whitespace: Whitespace = Whitespace.KEEP
660
+ ) -> EnvSpec:
661
+ """As `spec_of`, with two bodies: `begin` and `end`."""
662
+ declared_only = declared_only or cls.defined_by is not None
663
+ syntax = _syntax(cls, *cls.__tm_inline__)
664
+ if declared_only:
665
+ _declared_only(cls, *cls.__tm_inline__)
666
+ begin = end = ""
667
+ origin, refs = "declared", ()
668
+ else:
669
+ begin, begin_origin, begin_refs = _required(cls, cls.__tm_inline__[0], "begin", whitespace, syntax)
670
+ end, end_origin, end_refs = _required(cls, cls.__tm_inline__[1], "end", whitespace, syntax)
671
+ origin = " + ".join(dict.fromkeys((begin_origin, end_origin)))
672
+ refs = tuple(dict.fromkeys((*begin_refs, *end_refs)))
673
+ return EnvSpec(
674
+ cls.__name__,
675
+ cls.__tm_doc__,
676
+ cls.__tm_args__,
677
+ begin,
678
+ end,
679
+ origin,
680
+ f"{cls.__module__}.{cls.__qualname__}",
681
+ cls.__tm_unused__,
682
+ refs,
683
+ cls.__dict__.get("cs"),
684
+ syntax,
685
+ )
686
+
687
+
688
+ _KINDS = {
689
+ Length: "Length",
690
+ Colour: "Colour",
691
+ bool: "Bool",
692
+ str: "Text",
693
+ Raw: "Raw",
694
+ } # the closed set `Value[T]` accepts (T2)
695
+
696
+
697
+ class Value[T]:
698
+ """A typed setting: `\\newlength`, `\\definecolor`, `\\newif` or `\\newcommand`, chosen by `T`; `default` is an
699
+ instance of `T`. A `Value[Text]` of an adopted package may leave `default` out: the hand-written package has it."""
700
+
701
+ default: T # not ClassVar[T]: pyright refuses type variables in ClassVar; subclasses assign it in the class body
702
+ needs_packages: ClassVar[tuple[str, ...]] = () # data, not mutation: the package takes the union (M14)
703
+ __tm_doc__: ClassVar[str]
704
+ __tm_kind__: ClassVar[str]
705
+ __tm_type__: ClassVar[type | types.UnionType]
706
+ __tm_prefix__: ClassVar[str]
707
+
708
+ def __init__(self, value: T) -> None:
709
+ """A document's setting for this value (D1a): `FactGap(Length("3mm"))`, checked against the declared type."""
710
+ if not isinstance(value, type(self).__tm_type__):
711
+ raise DeclarationError(
712
+ f"{type(self).__name__}({value!r}): expected a {type(self).__tm_kind__}", code="TM-M014"
713
+ )
714
+ self.value = value
715
+
716
+ def __repr__(self) -> str:
717
+ return f"{type(self).__name__}({self.value!r})"
718
+
719
+ def __init_subclass__(cls, **kwargs: Any) -> None:
720
+ super().__init_subclass__(**kwargs)
721
+ doc = _document(cls)
722
+ arg = next(
723
+ (typing.get_args(b)[0] for b in cls.__dict__.get("__orig_bases__", ()) if typing.get_origin(b) is Value),
724
+ None,
725
+ )
726
+ if typing.get_origin(arg) is typing.Annotated and TEXT in typing.get_args(arg)[1:]:
727
+ arg = str # Text
728
+ if arg not in _KINDS:
729
+ raise DeclarationError(
730
+ f"{cls.__qualname__}: base must be Value of Length, Colour, Bool, Text or Raw, not {arg!r}",
731
+ code="TM-M012",
732
+ path=cls.__name__,
733
+ )
734
+ if "default" not in cls.__dict__ and arg not in (str, Raw):
735
+ raise DeclarationError(f"{cls.__qualname__}: no default", code="TM-M013", path=cls.__name__)
736
+ default = cls.__dict__.get("default")
737
+ if default is not None and not isinstance(default, arg):
738
+ raise DeclarationError(
739
+ f"{cls.__qualname__}: default {default!r} is not a {_KINDS[arg]}", code="TM-M014", path=cls.__name__
740
+ )
741
+ implied = ("xcolor",) if isinstance(default, (CMYK, RGB, Gray)) else ()
742
+ cls.__tm_doc__, cls.__tm_kind__, cls.__tm_type__ = doc, _KINDS[arg], arg
743
+ cls.needs_packages = tuple(sorted({*cls.needs_packages, *implied}))
744
+
745
+
746
+ type AnyValue = Value[Length] | Value[Colour] | Value[bool] | Value[Text] | Value[Raw]
747
+
748
+
749
+ def value_spec_of(cls: type[AnyValue]) -> ValueSpec:
750
+ return ValueSpec(
751
+ cls.__name__,
752
+ cls.__tm_doc__,
753
+ cls.__dict__.get("default"),
754
+ cls.__tm_kind__,
755
+ f"{cls.__module__}.{cls.__qualname__}",
756
+ cls.needs_packages,
757
+ )